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