@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -1,566 +0,0 @@
1
- /**
2
- * PanelListPanel — Shows all registered panels grouped by category.
3
- *
4
- * Features:
5
- * - Arrow keys to navigate, Enter to open the selected panel
6
- * - Open/closed indicator (● open, ○ closed)
7
- * - Press `/` to filter; typing narrows, Esc clears, action keys work outside filter mode
8
- * - Grouped by category
9
- * - `T` / `B` to place a panel in the top/bottom pane
10
- * - `M` to move an open panel to the other pane
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)
13
- *
14
- * Open via /panel list.
15
- */
16
- import type { Line, Cell } from '../types/grid.ts';
17
- import type { PanelCategory, PanelRegistration } from './types.ts';
18
- import { BasePanel } from './base-panel.ts';
19
- import type { PanelManager } from './panel-manager.ts';
20
- import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
21
- import { createEmptyLine } from '../types/grid.ts';
22
- import {
23
- buildEmptyState,
24
- buildKeyValueLine,
25
- buildPanelListRow,
26
- buildPanelLine,
27
- buildSearchInputLine,
28
- buildSummaryBlock,
29
- buildPanelWorkspace,
30
- DEFAULT_PANEL_PALETTE,
31
- extendPalette,
32
- resolvePrimaryScrollableSection,
33
- type PanelWorkspaceSection,
34
- } from './polish.ts';
35
- import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
36
- import { wrapWithHangingIndent } from '../renderer/text-layout.ts';
37
- import { isPanelSearchPrintable } from './search-focus.ts';
38
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
39
-
40
- // ── Colour palette ────────────────────────────────────────────────────────────
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
- ];
64
- const CATEGORY_LABELS: Record<PanelCategory, string> = {
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',
74
- };
75
-
76
- // ── Layout constants ──────────────────────────────────────────────────────────
77
- const NAME_COL_WIDTH = 22;
78
- const PREFIX_WIDTH = 5; // arrow + dot + health-flag + space + space
79
-
80
- /** A flat entry in the navigable list — either a section header or a panel row. */
81
- type ListEntry =
82
- | { kind: 'header'; label: string }
83
- | { kind: 'panel'; reg: PanelRegistration };
84
-
85
- /** Number of most-recently-opened panels to surface in the Recent group. */
86
- const RECENT_LIMIT = 5;
87
-
88
- function panelPlacementMarker(options: {
89
- isTopOpen: boolean;
90
- isBottomOpen: boolean;
91
- focusedPane: 'top' | 'bottom';
92
- }): { text: string; color: string } {
93
- const { isTopOpen, isBottomOpen, focusedPane } = options;
94
- if (isTopOpen && isBottomOpen) return { text: '◆', color: C.value };
95
- if (isTopOpen) return { text: focusedPane === 'top' ? '▲' : '△', color: C.info };
96
- if (isBottomOpen) return { text: focusedPane === 'bottom' ? '▼' : '▽', color: C.paneBottom };
97
- return { text: ' ', color: C.dim };
98
- }
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
-
114
- function wrapPanelDescription(text: string, width: number, maxLines = 2): string[] {
115
- if (width <= 0) return [''];
116
- const lines = wrapWithHangingIndent(text, width, '', maxLines);
117
- return lines.length > 0 ? lines.map((line) => truncateDisplay(line, width)) : [''];
118
- }
119
-
120
- // ── PanelListPanel ────────────────────────────────────────────────────────────
121
-
122
- export class PanelListPanel extends BasePanel {
123
- private _selectedIndex = 0;
124
- private _scrollOffset = 0;
125
- private _query = '';
126
- /** WO-153: converged modal '/' filter — mirrors ScrollableListPanel's _handleFilterKey contract. */
127
- private _filterActive = false;
128
- private _cachedEntries: ListEntry[] | null = null;
129
- private _entriesDirty = true;
130
-
131
- public constructor(
132
- private readonly panelManager: PanelManager,
133
- componentHealthMonitor?: ComponentHealthMonitor,
134
- ) {
135
- super('panel-list', 'Panel List', 'L', 'session', componentHealthMonitor);
136
- }
137
-
138
- public override onActivate(): void {
139
- super.onActivate();
140
- this._selectedIndex = 0;
141
- this._scrollOffset = 0;
142
- this._query = '';
143
- this._filterActive = false;
144
- this._entriesDirty = true;
145
- }
146
-
147
- protected override markDirty(): void {
148
- super.markDirty();
149
- this._entriesDirty = true;
150
- }
151
-
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 = '';
163
- this._selectedIndex = 0;
164
- this._scrollOffset = 0;
165
- this.markDirty();
166
- return true;
167
- }
168
- if (key === 'return' || key === 'enter') {
169
- this._filterActive = false; // commit; keep the query applied
170
- const selectedPanel = this._getSelectedPanelEntry(this._buildEntries());
171
- if (selectedPanel) {
172
- try {
173
- this.panelManager.open(selectedPanel.reg.id);
174
- } catch (err) {
175
- logger.warn(`[panel-list] failed to open panel: ${err}`);
176
- }
177
- }
178
- this.markDirty();
179
- return true;
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.
192
- if (isPanelSearchPrintable(key)) {
193
- this._query += key;
194
- this._selectedIndex = 0;
195
- this._scrollOffset = 0;
196
- this.markDirty();
197
- return true;
198
- }
199
- return false;
200
- }
201
- if (key === '/') {
202
- this._filterActive = true;
203
- this.markDirty();
204
- return true;
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
- /**
228
- * The `/`-to-filter buffer wants every character of a burst (paste, or a
229
- * fast-typed query landing in one input.feed() call), same as it always
230
- * has — see the interface doc on `Panel.isCapturingTextBurst`.
231
- */
232
- public isCapturingTextBurst(): boolean {
233
- return this._filterActive;
234
- }
235
-
236
- public handleInput(key: string): boolean {
237
- const filterResult = this._handleFilterKey(key);
238
- if (filterResult !== null) return filterResult;
239
-
240
- const entries = this._buildEntries();
241
-
242
- // Navigation
243
- if (key === 'up' || key === 'k') {
244
- this._movePrevPanel();
245
- this.markDirty();
246
- return true;
247
- }
248
- if (key === 'down' || key === 'j') {
249
- this._moveNextPanel(entries);
250
- this.markDirty();
251
- return true;
252
- }
253
-
254
- // Open selected panel
255
- if (key === 'return' || key === 'enter') {
256
- const selectedPanel = this._getSelectedPanelEntry(entries);
257
- if (selectedPanel) {
258
- try {
259
- this.panelManager.open(selectedPanel.reg.id);
260
- } catch (err) {
261
- logger.warn(`[panel-list] failed to open panel: ${err}`);
262
- }
263
- this.markDirty();
264
- }
265
- return true;
266
- }
267
-
268
- if (key === 'T' || key === 'B') {
269
- const selectedPanel = this._getSelectedPanelEntry(entries);
270
- if (selectedPanel) {
271
- const pane = key === 'T' ? 'top' : 'bottom';
272
- try {
273
- const pm = this.panelManager;
274
- pm.open(selectedPanel.reg.id, pane);
275
- pm.show();
276
- } catch (err) {
277
- logger.warn(`[panel-list] failed to place panel: ${err}`);
278
- }
279
- this.markDirty();
280
- }
281
- return true;
282
- }
283
-
284
- if (key === 'M') {
285
- const selectedPanel = this._getSelectedPanelEntry(entries);
286
- if (selectedPanel) {
287
- try {
288
- this.panelManager.moveToOtherPane(selectedPanel.reg.id);
289
- } catch (err) {
290
- logger.warn(`[panel-list] failed to move panel: ${err}`);
291
- }
292
- this.markDirty();
293
- }
294
- return true;
295
- }
296
-
297
- if (key === 'S') {
298
- this.panelManager.toggleBottomPane();
299
- this.markDirty();
300
- return true;
301
- }
302
-
303
- if (key === 'w') {
304
- const selectedPanel = this._getSelectedPanelEntry(entries);
305
- if (selectedPanel) {
306
- try {
307
- // Existing public PanelManager.close() API only — panel-manager.ts
308
- // itself is WO-150's surface this wave.
309
- this.panelManager.close(selectedPanel.reg.id);
310
- } catch (err) {
311
- logger.warn(`[panel-list] failed to close panel: ${err}`);
312
- }
313
- this.markDirty();
314
- }
315
- return true;
316
- }
317
-
318
- if (key === 'tab') {
319
- this.panelManager.togglePaneFocus();
320
- this.markDirty();
321
- return true;
322
- }
323
-
324
- return false;
325
- }
326
-
327
- public render(width: number, height: number): Line[] {
328
- if (!this.canRenderNow()) {
329
- return Array.from({ length: height }, () => createEmptyLine(width));
330
- }
331
- const start = Date.now();
332
- this.needsRender = false;
333
- const intro = 'Browse, place, split, and move panels without dropping into raw panel-management commands.';
334
- const entries = this._buildEntries();
335
-
336
- if (entries.length === 0) {
337
- const lines = buildPanelWorkspace(width, height, {
338
- title: 'Panel Workspace',
339
- intro,
340
- sections: [{
341
- title: 'Filter',
342
- lines: [this._buildFilterLine(width)],
343
- }, {
344
- lines: buildEmptyState(
345
- width,
346
- ' No panels match filter.',
347
- 'Clear the filter or search for another panel by id, name, description, or category.',
348
- // The real remedy for a stuck filter is clearing it in-panel;
349
- // '/panel list' would just re-activate this same instance with
350
- // the filter still applied. A committed filter needs '/' to
351
- // re-enter editing before Esc clears it.
352
- [{ command: '/ then Esc', summary: 'clear the filter and show every panel' }],
353
- C,
354
- ),
355
- }],
356
- palette: C,
357
- });
358
- while (lines.length < height) lines.push(createEmptyLine(width));
359
- if (lines.length > height) lines.length = height;
360
- this.reportRenderDuration(Date.now() - start);
361
- return lines;
362
- }
363
-
364
- const panelEntries = entries.filter(e => e.kind === 'panel');
365
- const pm = this.panelManager;
366
- const topIds = new Set(pm.getTopPane().panels.map(p => p.id));
367
- const bottomIds = new Set(pm.getBottomPane().panels.map(p => p.id));
368
- const focusedPane = pm.getFocusedPane();
369
- 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]])];
370
- // Optional health rollup — only present when a ComponentHealthMonitor was
371
- // injected (fills the gap left by WO-004's panel-resources panel removal).
372
- const allHealth = this.componentHealthMonitor?.getAllHealth() ?? [];
373
- const unhealthyCount = allHealth.filter((h) => h.healthStatus !== 'healthy').length;
374
-
375
- const postureLines: Line[] = [
376
- buildKeyValueLine(width, [
377
- { label: 'visible panels', value: String(pm.getAllOpen().length), valueColor: pm.getAllOpen().length > 0 ? C.value : C.dim },
378
- { label: 'focused pane', value: focusedPane, valueColor: focusedPane === 'top' ? C.info : C.paneBottom },
379
- { label: 'split', value: pm.isBottomPaneVisible() ? 'dual' : 'single', valueColor: pm.isBottomPaneVisible() ? C.info : C.dim },
380
- { label: 'results', value: String(panelEntries.length), valueColor: C.value },
381
- ...(this.componentHealthMonitor
382
- ? [{ label: 'flagged', value: String(unhealthyCount), valueColor: unhealthyCount > 0 ? C.warn : C.good }]
383
- : []),
384
- ], C),
385
- buildPanelLine(width, [[` Filter owns input only when selected. Open and switch operations should land directly in focused panel state.`, C.header]]),
386
- ];
387
- const entryLines: Line[] = [this._buildFilterLine(width)];
388
- const renderedBlocks: Array<{ entry: ListEntry; lines: Line[]; panelFlatIndex?: number }> = [];
389
- let flatPanelIndex = 0;
390
- for (const entry of entries) {
391
- if (entry.kind === 'header') {
392
- const label = ` ── ${entry.label} ${'─'.repeat(Math.max(0, width - 6 - entry.label.length))}`;
393
- renderedBlocks.push({
394
- entry,
395
- lines: [buildPanelLine(width, [[truncateDisplay(label, width), C.label, C.headerBg]])],
396
- });
397
- } else {
398
- const flatIdx = flatPanelIndex++;
399
- const isSelected = flatIdx === this._selectedIndex;
400
- const isTopOpen = topIds.has(entry.reg.id);
401
- const isBottomOpen = bottomIds.has(entry.reg.id);
402
- const dot = isTopOpen || isBottomOpen ? '●' : '○';
403
- const dotColor = isTopOpen || isBottomOpen ? C.good : C.dim;
404
- const nameColor = C.value;
405
- const nameStr = fitDisplay(entry.reg.name, NAME_COL_WIDTH);
406
- const descStartCol = PREFIX_WIDTH + NAME_COL_WIDTH + 1;
407
- const descWidth = Math.max(1, width - descStartCol);
408
- const descLines = wrapPanelDescription(entry.reg.description, descWidth, 2);
409
- const placement = panelPlacementMarker({ isTopOpen, isBottomOpen, focusedPane });
410
- const health = panelHealthMarker(this.componentHealthMonitor?.getHealth(entry.reg.id));
411
- // When searching, the flat result list loses its category grouping — so
412
- // tag each result with a dim [Category] so the origin stays discoverable.
413
- const categoryTag = this._query
414
- ? `[${CATEGORY_LABELS[entry.reg.category]}] `
415
- : '';
416
- const blockLines: Line[] = [
417
- buildPanelListRow(width, [
418
- { text: dot, fg: dotColor },
419
- { text: placement.text, fg: placement.color },
420
- { text: health.text, fg: health.color },
421
- { text: ' ', fg: C.dim },
422
- { text: `${nameStr} `, fg: nameColor },
423
- { text: categoryTag, fg: C.label },
424
- { text: descLines[0] ?? '', fg: C.label },
425
- ], C, { selected: isSelected, selectedBg: C.selectBg, markerColor: C.info }),
426
- ];
427
- if ((descLines[1] ?? '').length > 0) {
428
- blockLines.push(buildPanelLine(width, [
429
- [' '.repeat(PREFIX_WIDTH), C.dim, isSelected ? C.selectBg : C.surfaceBg],
430
- [' '.repeat(NAME_COL_WIDTH), C.dim, isSelected ? C.selectBg : C.surfaceBg],
431
- [' ', C.dim, isSelected ? C.selectBg : C.surfaceBg],
432
- [descLines[1] ?? '', C.label, isSelected ? C.selectBg : C.surfaceBg],
433
- ]));
434
- }
435
- renderedBlocks.push({ entry, lines: blockLines, panelFlatIndex: flatIdx });
436
- }
437
- }
438
- const blockStarts: number[] = [];
439
- let totalRows = 0;
440
- for (const block of renderedBlocks) {
441
- blockStarts.push(totalRows);
442
- totalRows += block.lines.length;
443
- }
444
- const selectedEntryIndex = renderedBlocks.findIndex((block) => block.panelFlatIndex === this._selectedIndex);
445
- const selectedRow = selectedEntryIndex >= 0 ? blockStarts[selectedEntryIndex] ?? 0 : 0;
446
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Panel posture', postureLines, C) };
447
- const resolvedSection = resolvePrimaryScrollableSection(width, height, {
448
- intro,
449
- footerLines,
450
- palette: C,
451
- beforeSections: [postureSection],
452
- section: {
453
- title: 'Panels',
454
- fixedLines: entryLines,
455
- scrollableLines: renderedBlocks.flatMap((block) => block.lines),
456
- selectedIndex: selectedRow,
457
- scrollOffset: this._scrollOffset,
458
- guardRows: 1,
459
- minRows: 1,
460
- appendWindowSummary: {
461
- dimColor: C.dim,
462
- },
463
- },
464
- });
465
- this._scrollOffset = resolvedSection.scrollOffset;
466
-
467
- const sections: PanelWorkspaceSection[] = [
468
- postureSection,
469
- resolvedSection.section,
470
- ];
471
- const lines = buildPanelWorkspace(width, height, {
472
- title: 'Panel Workspace',
473
- intro,
474
- sections,
475
- footerLines,
476
- palette: C,
477
- });
478
-
479
- while (lines.length < height) lines.push(createEmptyLine(width));
480
- if (lines.length > height) lines.length = height;
481
- this.reportRenderDuration(Date.now() - start);
482
- return lines;
483
- }
484
-
485
- // ── Private helpers ───────────────────────────────────────────────────────
486
-
487
- /** Build flat list of entries (headers + panel rows) filtered by query. */
488
- private _buildEntries(): ListEntry[] {
489
- if (this._cachedEntries !== null && !this._entriesDirty) {
490
- return this._cachedEntries;
491
- }
492
-
493
- const manager = this.panelManager;
494
- const byCategory = manager.getTypesByCategory();
495
- const q = this._query.toLowerCase();
496
- const entries: ListEntry[] = [];
497
-
498
- // Recent group (browse mode only): the most-recently-opened panels, newest
499
- // first, so frequently used panels are one keystroke away.
500
- if (!q) {
501
- const byId = new Map(manager.getRegisteredTypes().map((r) => [r.id, r]));
502
- const recent: PanelRegistration[] = [];
503
- for (const id of manager.getRecentlyOpened()) {
504
- const reg = byId.get(id);
505
- if (reg) recent.push(reg);
506
- if (recent.length >= RECENT_LIMIT) break;
507
- }
508
- if (recent.length > 0) {
509
- entries.push({ kind: 'header', label: 'Recent' });
510
- for (const reg of recent) entries.push({ kind: 'panel', reg });
511
- }
512
- }
513
-
514
- for (const cat of CATEGORY_ORDER) {
515
- const regs = byCategory.get(cat) ?? [];
516
- const filtered = q
517
- ? regs.filter(r =>
518
- r.id.toLowerCase().includes(q) ||
519
- r.name.toLowerCase().includes(q) ||
520
- r.description.toLowerCase().includes(q) ||
521
- r.category.toLowerCase().includes(q)
522
- )
523
- : regs;
524
-
525
- if (filtered.length === 0) continue;
526
-
527
- entries.push({ kind: 'header', label: CATEGORY_LABELS[cat] });
528
- for (const reg of filtered) {
529
- entries.push({ kind: 'panel', reg });
530
- }
531
- }
532
-
533
- this._cachedEntries = entries;
534
- this._entriesDirty = false;
535
- return entries;
536
- }
537
-
538
- /** Get the ListEntry for the currently selected panel. */
539
- private _getSelectedPanelEntry(entries: ListEntry[]): Extract<ListEntry, { kind: 'panel' }> | null {
540
- let idx = 0;
541
- for (const e of entries) {
542
- if (e.kind !== 'panel') continue;
543
- if (idx === this._selectedIndex) return e;
544
- idx++;
545
- }
546
- return null;
547
- }
548
-
549
- /** Move selection to the previous panel entry. */
550
- private _movePrevPanel(): void {
551
- this._selectedIndex = Math.max(0, this._selectedIndex - 1);
552
- }
553
-
554
- /** Move selection to the next panel entry. */
555
- private _moveNextPanel(entries: ListEntry[]): void {
556
- const panelCount = entries.filter(e => e.kind === 'panel').length;
557
- this._selectedIndex = Math.min(panelCount - 1, this._selectedIndex + 1);
558
- }
559
-
560
- /** Clamp selectedIndex within available panels. */
561
- private _clampSelection(entries: ListEntry[]): void {
562
- const panelCount = entries.filter(e => e.kind === 'panel').length;
563
- this._selectedIndex = Math.max(0, Math.min(this._selectedIndex, panelCount - 1));
564
- }
565
-
566
- }