@pellux/goodvibes-tui 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -9,28 +9,39 @@ import type { HookChain, HookDefinition } from '@pellux/goodvibes-sdk/platform/h
9
9
  import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks';
10
10
  import { truncateDisplay } from '../utils/terminal-width.ts';
11
11
  import {
12
+ type ConfirmState,
13
+ handleConfirmInput,
14
+ renderConfirmLines,
15
+ } from './confirm-state.ts';
16
+ import {
17
+ buildKeyValueLine,
18
+ buildKeyboardHints,
12
19
  buildPanelLine,
13
20
  buildStatusPill,
14
21
  DEFAULT_PANEL_PALETTE,
22
+ resolveScrollablePanelSection,
15
23
  } from './polish.ts';
16
24
 
17
- const C = {
18
- ...DEFAULT_PANEL_PALETTE,
19
- header: '#94a3b8',
20
- headerBg: '#1e293b',
21
- ok: '#22c55e',
22
- warn: '#eab308',
23
- error: '#ef4444',
24
- info: '#38bdf8',
25
- selectBg: '#0f172a',
26
- } as const;
25
+ // Base chrome only — title band, state colors, and text tokens all come
26
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
27
+ const C = DEFAULT_PANEL_PALETTE;
27
28
 
29
+ /**
30
+ * WO-134: widened past read-only listing to also carry the three managed-hook
31
+ * mutation methods the panel drives directly (t=toggle, x=remove, s=simulate),
32
+ * mirroring the /hooks enable|disable|remove|simulate subcommands
33
+ * (hooks-runtime.ts). The raw `HookWorkbench` class instance passed at
34
+ * bootstrap already implements all three.
35
+ */
28
36
  export interface HooksPanelWorkbenchView {
29
37
  listManagedHooks(): Array<{ pattern: string; hook: HookDefinition }>;
30
38
  listManagedChains(): HookChain[];
31
39
  listRecentActions(limit?: number): HookAuthoringAction[];
32
40
  getLastSimulation(): HookSimulationResult | null;
33
41
  getHooksFilePath(): string;
42
+ toggleManagedHook(name: string, enabled: boolean): boolean;
43
+ removeManagedEntry(name: string): boolean;
44
+ simulate(eventPath: string, payload?: Record<string, unknown>): HookSimulationResult;
34
45
  }
35
46
 
36
47
  export interface HooksPanelDataSource {
@@ -59,6 +70,10 @@ type HookEntry = { pattern: string; hook: HookDefinition };
59
70
 
60
71
  export class HooksPanel extends ScrollableListPanel<HookEntry> {
61
72
  private readonly dataSource: HooksPanelDataSource;
73
+ /** Pending confirm for 'x' remove — destructive, so it goes through the project-standard confirm contract. Subject is the managed hook name. */
74
+ private confirmAction: ConfirmState<string> | null = null;
75
+ /** Toggled by 'a' — widens the Recent Activity window from a short glance to a fuller scrollable review. */
76
+ private _activityExpanded = false;
62
77
 
63
78
  public constructor(
64
79
  hookDispatcher: Pick<HookDispatcher, 'listHooks' | 'getChains'>,
@@ -66,11 +81,19 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
66
81
  hookActivityTracker: Pick<HookActivityTracker, 'listRecent'>,
67
82
  dataSource: HooksPanelDataSource = createDefaultDataSource(hookDispatcher, hookWorkbench, hookActivityTracker),
68
83
  ) {
69
- super('hooks', 'Hooks', 'H', 'monitoring');
84
+ super('hooks', 'Hooks', '', 'automation-control');
70
85
  this.showSelectionGutter = true; // I5: non-color selection affordance
86
+ this.filterEnabled = true;
87
+ this.filterLabel = 'Filter hooks';
71
88
  this.dataSource = dataSource;
72
89
  }
73
90
 
91
+ protected override filterMatches(entry: HookEntry, q: string): boolean {
92
+ return (entry.hook.name ?? '').toLowerCase().includes(q)
93
+ || entry.pattern.toLowerCase().includes(q)
94
+ || entry.hook.type.toLowerCase().includes(q);
95
+ }
96
+
74
97
  protected override getPalette() { return C; }
75
98
  protected override getEmptyStateMessage() { return ' No hooks are currently registered.'; }
76
99
  protected override getEmptyStateActions() {
@@ -96,19 +119,113 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
96
119
  }
97
120
 
98
121
  public handleInput(key: string): boolean {
99
- if (key === 'r') {
100
- this.markDirty();
122
+ if (this.lastError !== null) this.clearError();
123
+
124
+ if (this.confirmAction) {
125
+ const result = handleConfirmInput(this.confirmAction, key);
126
+ if (result === 'confirmed') {
127
+ this._removeManaged(this.confirmAction.subject);
128
+ this.confirmAction = null;
129
+ this.markDirty();
130
+ return true;
131
+ }
132
+ if (result === 'cancelled') {
133
+ this.confirmAction = null;
134
+ this.markDirty();
135
+ }
101
136
  return true;
102
137
  }
138
+
139
+ if (!this.filterActive) {
140
+ switch (key) {
141
+ case 'r':
142
+ this.markDirty();
143
+ return true;
144
+ case 't':
145
+ this._toggleSelected();
146
+ return true;
147
+ case 'x':
148
+ this._requestRemove();
149
+ return true;
150
+ case 's':
151
+ this._simulateSelected();
152
+ return true;
153
+ case 'a':
154
+ this._activityExpanded = !this._activityExpanded;
155
+ this.markDirty();
156
+ return true;
157
+ default:
158
+ break;
159
+ }
160
+ }
161
+
103
162
  return super.handleInput(key);
104
163
  }
105
164
 
165
+ private _selectedEntry(): HookEntry | undefined {
166
+ return this.getSelectedItem();
167
+ }
168
+
169
+ /**
170
+ * WO-134: t=toggle — mirrors `/hooks enable|disable <name>` (hooks-runtime.ts:64-77),
171
+ * flipping the selected hook's managed enabled state via workbench.toggleManagedHook.
172
+ */
173
+ private _toggleSelected(): void {
174
+ const entry = this._selectedEntry();
175
+ if (!entry) return;
176
+ const name = entry.hook.name;
177
+ if (!name) {
178
+ this.setError('This hook has no managed name to toggle.');
179
+ this.markDirty();
180
+ return;
181
+ }
182
+ const nextEnabled = entry.hook.enabled === false;
183
+ const changed = this.dataSource.getWorkbench().toggleManagedHook(name, nextEnabled);
184
+ if (!changed) this.setError(`No managed hook named '${name}'.`);
185
+ this.markDirty();
186
+ }
187
+
188
+ private _requestRemove(): void {
189
+ const entry = this._selectedEntry();
190
+ if (!entry) return;
191
+ const name = entry.hook.name;
192
+ if (!name) {
193
+ this.setError('This hook has no managed name to remove.');
194
+ this.markDirty();
195
+ return;
196
+ }
197
+ this.confirmAction = { subject: name, label: name, verb: 'Remove' };
198
+ this.markDirty();
199
+ }
200
+
201
+ private _removeManaged(name: string): void {
202
+ const removed = this.dataSource.getWorkbench().removeManagedEntry(name);
203
+ if (!removed) this.setError(`No managed hook or chain named '${name}'.`);
204
+ this.markDirty();
205
+ }
206
+
207
+ /**
208
+ * WO-134: s=simulate — mirrors `/hooks simulate <eventPath>` (hooks-runtime.ts:78-93)
209
+ * using the selected hook's own pattern as the simulated event path. The
210
+ * result lands in workbench.getLastSimulation(), already rendered below.
211
+ */
212
+ private _simulateSelected(): void {
213
+ const entry = this._selectedEntry();
214
+ if (!entry) return;
215
+ this.dataSource.getWorkbench().simulate(entry.pattern);
216
+ this.markDirty();
217
+ }
218
+
106
219
  public render(width: number, height: number): Line[] {
107
220
  this.clampSelection();
108
221
  const hooks = this.dataSource.listHooks();
109
222
  const contracts = this.dataSource.listContracts();
110
223
  const chains = this.dataSource.listChains();
111
- const recentActivity = this.dataSource.listRecentActivity(3);
224
+ // Fixed-window stats stay stable regardless of the 'a' expand toggle below;
225
+ // only the displayed activity rows grow when expanded.
226
+ const recentActivityForStats = this.dataSource.listRecentActivity(3);
227
+ const activityLimit = this._activityExpanded ? 20 : 3;
228
+ const recentActivity = this.dataSource.listRecentActivity(activityLimit);
112
229
  const workbench = this.dataSource.getWorkbench();
113
230
  const managedHooks = workbench.listManagedHooks();
114
231
  const managedChains = workbench.listManagedChains();
@@ -116,7 +233,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
116
233
  const lastSimulation = workbench.getLastSimulation();
117
234
  const intro = 'Hook contracts, active registrations, managed authoring, recent runtime activity, and simulation matches.';
118
235
 
119
- const selected = hooks[this.selectedIndex];
236
+ const selected = this.getSelectedItem();
120
237
  const contract = selected ? contracts.find((c) => c.pattern === selected.pattern) : undefined;
121
238
 
122
239
  const detailLines: Line[] = [];
@@ -157,10 +274,10 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
157
274
  ]));
158
275
  }
159
276
 
160
- const activityLines: Line[] = recentActivity.length === 0
277
+ const activityRows: Line[] = recentActivity.length === 0
161
278
  ? [buildPanelLine(width, [[' No hook activity recorded yet.', C.empty]])]
162
279
  : recentActivity.map((record) => {
163
- const color = !record.ok ? C.error : record.decision === 'deny' ? C.warn : C.ok;
280
+ const color = !record.ok ? C.bad : record.decision === 'deny' ? C.warn : C.good;
164
281
  return buildPanelLine(width, [
165
282
  [' ', C.label],
166
283
  [truncateDisplay(record.hookName, 18).padEnd(18), C.value],
@@ -171,6 +288,20 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
171
288
  ]);
172
289
  });
173
290
 
291
+ // WO-134: 'a' expands the activity window from a short glance (3 rows) to
292
+ // a fuller scrollable review (up to 20); resolveScrollablePanelSection
293
+ // appends a "+N more" summary row when even the expanded fetch overflows
294
+ // the available space, so the list never silently truncates unlabeled.
295
+ const activityLines: readonly Line[] = resolveScrollablePanelSection(width, height, {
296
+ palette: C,
297
+ section: {
298
+ scrollableLines: activityRows,
299
+ scrollOffset: 0,
300
+ minRows: this._activityExpanded ? 8 : 3,
301
+ appendWindowSummary: recentActivity.length > 0 ? { dimColor: C.dim } : undefined,
302
+ },
303
+ }).section.lines;
304
+
174
305
  const authoringLines: Line[] = recentAuthoring.length === 0
175
306
  ? [buildPanelLine(width, [[' No managed hook authoring actions recorded yet.', C.empty]])]
176
307
  : recentAuthoring.map((action) => buildPanelLine(width, [
@@ -224,15 +355,46 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
224
355
  });
225
356
  }
226
357
 
358
+ // Summary header — surface registration + activity counts first so the most
359
+ // important "what's firing" signal is visible without scrolling to the footer.
360
+ const denials = recentActivityForStats.filter((r) => r.ok && r.decision === 'deny').length;
361
+ const errors = recentActivityForStats.filter((r) => !r.ok).length;
362
+ const headerLines: Line[] = [
363
+ buildKeyValueLine(width, [
364
+ { label: 'hooks', value: String(hooks.length), valueColor: C.info },
365
+ { label: 'chains', value: String(chains.length), valueColor: C.value },
366
+ { label: 'contracts', value: String(contracts.length), valueColor: C.value },
367
+ { label: 'recent denials', value: String(denials), valueColor: denials > 0 ? C.warn : C.dim },
368
+ { label: 'errors', value: String(errors), valueColor: errors > 0 ? C.bad : C.dim },
369
+ ], C),
370
+ ];
371
+
372
+ const hints = this.filterActive
373
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
374
+ : [
375
+ { keys: 'Up/Down', label: 'move' },
376
+ { keys: 't', label: 'toggle' },
377
+ { keys: 'x', label: 'remove' },
378
+ { keys: 's', label: 'simulate' },
379
+ { keys: 'a', label: this._activityExpanded ? 'collapse activity' : 'expand activity' },
380
+ { keys: 'r', label: 'refresh' },
381
+ { keys: '/', label: 'filter' },
382
+ ];
383
+
227
384
  return this.renderList(width, height, {
228
385
  title: 'Hooks Control Room',
386
+ header: headerLines,
229
387
  footer: [
230
388
  ...detailLines,
231
- buildPanelLine(width, [[' Recent Activity', C.label]]),
389
+ buildPanelLine(width, [
390
+ [' Recent Activity', C.label],
391
+ [this._activityExpanded ? ' (a to collapse)' : ' (a to expand)', C.dim],
392
+ ]),
232
393
  ...activityLines,
233
394
  buildPanelLine(width, [[' Authoring', C.label]]),
234
395
  ...authoringLines,
235
- buildPanelLine(width, [[' Up/Down move r refresh /hooks for full contract listing', C.dim]]),
396
+ ...(this.confirmAction ? renderConfirmLines(width, this.confirmAction) : []),
397
+ buildKeyboardHints(width, hints, C),
236
398
  ],
237
399
  });
238
400
  }
@@ -1,5 +1,15 @@
1
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
1
2
  import type { Line } from '../types/grid.ts';
2
- import type { ForensicsRegistry } from '@/runtime/index.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
4
+ import { formatShortDuration } from '../utils/format-duration.ts';
5
+ import type {
6
+ ForensicsRegistry,
7
+ FailureReport,
8
+ ForensicsBundle,
9
+ CausalChainEntry,
10
+ PhaseTimingEntry,
11
+ ForensicsJumpLink,
12
+ } from '@/runtime/index.ts';
3
13
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
14
  import {
5
15
  buildBodyText,
@@ -12,16 +22,12 @@ import {
12
22
  DEFAULT_PANEL_PALETTE,
13
23
  type PanelPalette,
14
24
  } from './polish.ts';
15
- import type { FailureReport } from '@/runtime/index.ts';
25
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
26
+ import type { PanelIntegrationContext } from './types.ts';
16
27
 
17
- const C = {
18
- ...DEFAULT_PANEL_PALETTE,
19
- header: '#cbd5e1',
20
- headerBg: '#0f172a',
21
- warn: '#f59e0b',
22
- bad: '#ef4444',
23
- selectBg: '#111827',
24
- } as const;
28
+ // Base chrome only — title band, state colors, and text tokens all come
29
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
30
+ const C = DEFAULT_PANEL_PALETTE;
25
31
 
26
32
  function classificationColor(value: string): string {
27
33
  switch (value) {
@@ -35,15 +41,53 @@ function classificationColor(value: string): string {
35
41
  }
36
42
  }
37
43
 
44
+ function fmtTime(ts: number): string {
45
+ const d = new Date(ts);
46
+ const hh = String(d.getHours()).padStart(2, '0');
47
+ const mm = String(d.getMinutes()).padStart(2, '0');
48
+ const ss = String(d.getSeconds()).padStart(2, '0');
49
+ return `${hh}:${mm}:${ss}`;
50
+ }
51
+
52
+ /**
53
+ * Default workspace-relative destination for a panel-triggered `x` export.
54
+ * Left relative (not resolved against home/cwd here) so the `/incident
55
+ * export` command's own `shellPaths.resolveWorkspacePath` — the composition
56
+ * root's owned path resolver — decides the final absolute location.
57
+ */
58
+ function buildIncidentExportPath(id: string): string {
59
+ return `goodvibes-exports/incident-${id}-${Date.now()}.json`;
60
+ }
61
+
38
62
  export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
39
63
  private readonly registry?: ForensicsRegistry;
40
64
  private readonly unsub: (() => void) | null;
41
65
 
66
+ // Inline confirm for the (non-destructive but deliberate) capture action.
67
+ private _confirm: ConfirmState<string> | null = null;
68
+
69
+ // Pending cross-panel actions resolved via handlePanelIntegrationAction,
70
+ // which is the only place `executeCommand`/`panelManager` are available.
71
+ private _pendingExport: string | null = null;
72
+ private _pendingCapture: string | null = null;
73
+ private _pendingJump: ForensicsJumpLink | null = null;
74
+
75
+ // Cache buildBundle(id) per selected report id instead of recomputing it
76
+ // on every render call; invalidated whenever the registry changes.
77
+ private _bundleCache: { readonly id: string; readonly bundle: ForensicsBundle | undefined } | null = null;
78
+
42
79
  public constructor(registry?: ForensicsRegistry) {
43
- super('incident', 'Incident Review', 'N', 'monitoring');
80
+ super('incident', 'Incident Review', '', 'incidents-diagnostics');
44
81
  this.showSelectionGutter = true; // I5: non-color selection affordance
82
+ this.filterEnabled = true;
83
+ this.filterLabel = 'Filter incidents';
45
84
  this.registry = registry;
46
- this.unsub = registry ? registry.subscribe(() => this.markDirty()) : null;
85
+ this.unsub = registry
86
+ ? registry.subscribe(() => {
87
+ this._bundleCache = null;
88
+ this.markDirty();
89
+ })
90
+ : null;
47
91
  }
48
92
 
49
93
  public override onDestroy(): void {
@@ -58,13 +102,19 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
58
102
  return this.registry?.getAll() ?? [];
59
103
  }
60
104
 
105
+ protected override filterMatches(report: FailureReport, q: string): boolean {
106
+ return report.classification.toLowerCase().includes(q)
107
+ || report.id.toLowerCase().includes(q)
108
+ || (report.summary ?? '').toLowerCase().includes(q);
109
+ }
110
+
61
111
  protected renderItem(report: FailureReport, index: number, selected: boolean, width: number): Line {
62
112
  const bg = selected ? C.selectBg : undefined;
63
113
  return buildPanelLine(width, [
64
114
  [' ', C.label, bg],
65
- [report.id.slice(0, 8).padEnd(9), C.dim, bg],
66
- [report.classification.padEnd(20), classificationColor(report.classification), bg],
67
- [report.summary.slice(0, Math.max(0, width - 31)), C.value, bg],
115
+ [fitDisplay(report.id, 9), C.dim, bg],
116
+ [fitDisplay(report.classification, 20), classificationColor(report.classification), bg],
117
+ [truncateDisplay(report.summary, Math.max(0, width - 31)), C.value, bg],
68
118
  ]);
69
119
  }
70
120
 
@@ -79,8 +129,152 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
79
129
  ];
80
130
  }
81
131
 
132
+ private getBundle(id: string): ForensicsBundle | undefined {
133
+ if (!this.registry) return undefined;
134
+ if (!this._bundleCache || this._bundleCache.id !== id) {
135
+ this._bundleCache = { id, bundle: this.registry.buildBundle(id) };
136
+ }
137
+ return this._bundleCache.bundle;
138
+ }
139
+
140
+ private _pushPhaseTimingLine(lines: Line[], pt: PhaseTimingEntry, width: number): void {
141
+ const statusChar = pt.success ? '✓' : '✕';
142
+ const statusColor = pt.success ? C.good : C.bad;
143
+ const dur = formatShortDuration(pt.durationMs);
144
+ const phaseLabel = fitDisplay(pt.phase, 14);
145
+ const errPart = pt.error ? ` ${truncateDisplay(pt.error, Math.max(0, width - 34))}` : '';
146
+ lines.push(buildPanelLine(width, [
147
+ [' ', C.dim],
148
+ [`${statusChar} `, statusColor],
149
+ [phaseLabel, C.value],
150
+ [dur.padStart(8, ' '), C.label],
151
+ [errPart, C.bad],
152
+ ]));
153
+ }
154
+
155
+ private _pushCausalLine(lines: Line[], entry: CausalChainEntry, width: number): void {
156
+ const prefix = entry.isRootCause ? ' * ' : ' - ';
157
+ const color = entry.isRootCause ? C.bad : C.dim;
158
+ const timeStr = fmtTime(entry.ts);
159
+ const descMax = Math.max(0, width - prefix.length - 9);
160
+ lines.push(buildPanelLine(width, [
161
+ [prefix, color],
162
+ [`${timeStr} `, C.label],
163
+ [truncateDisplay(entry.description, descMax), color],
164
+ ]));
165
+ }
166
+
167
+ private _pushJumpLinkLine(lines: Line[], link: ForensicsJumpLink, width: number): void {
168
+ const kindTag = link.kind === 'panel' ? '[panel]' : '[cmd] ';
169
+ lines.push(buildPanelLine(width, [
170
+ [' ', C.dim],
171
+ [kindTag, C.label],
172
+ [` ${link.label}`, C.info],
173
+ [link.args ? ` (${link.args})` : '', C.dim],
174
+ ]));
175
+ }
176
+
177
+ /**
178
+ * Decision-making for every key: navigation is delegated to
179
+ * ScrollableListPanel, while `x`/`c`/`j` and any in-progress confirm are
180
+ * resolved here and staged as a pending action. The actual cross-panel
181
+ * side effect (executeCommand / panelManager.open) requires the
182
+ * PanelIntegrationContext, which is only available in
183
+ * `handlePanelIntegrationAction` — invoked immediately after this method
184
+ * returns `true` for the same key.
185
+ */
186
+ public override handleInput(key: string): boolean {
187
+ if (this.lastError !== null) this.clearError();
188
+
189
+ if (this._confirm) {
190
+ const outcome = handleConfirmInput(this._confirm, key);
191
+ if (outcome === 'confirmed') {
192
+ this._pendingCapture = this._confirm.subject;
193
+ this._confirm = null;
194
+ this.markDirty();
195
+ return true;
196
+ }
197
+ if (outcome === 'cancelled') {
198
+ this._confirm = null;
199
+ this.markDirty();
200
+ return true;
201
+ }
202
+ return true; // absorbed — keep the confirm dialog pending
203
+ }
204
+
205
+ // Actions must target the filtered view the list highlights — raw
206
+ // getItems() desyncs selectedIndex under an applied '/' filter.
207
+ const reports = this.getVisibleItems();
208
+ if (reports.length > 0 && (key === 'x' || key === 'c' || key === 'j')) {
209
+ this.clampSelection();
210
+ const selected = this.getSelectedItem();
211
+ if (selected) {
212
+ if (key === 'x') {
213
+ this._pendingExport = selected.id;
214
+ return true;
215
+ }
216
+ if (key === 'c') {
217
+ this._confirm = { subject: selected.id, label: `incident ${selected.id}`, verb: 'Capture' };
218
+ this.markDirty();
219
+ return true;
220
+ }
221
+ if (key === 'j') {
222
+ const link = selected.jumpLinks[0];
223
+ if (link) {
224
+ this._pendingJump = link;
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+ }
230
+ }
231
+
232
+ return super.handleInput(key);
233
+ }
234
+
235
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
236
+ if (this._pendingExport) {
237
+ const id = this._pendingExport;
238
+ this._pendingExport = null;
239
+ const path = buildIncidentExportPath(id);
240
+ void ctx.executeCommand?.('incident', ['export', id, path]).catch((err) => {
241
+ logger.debug('incident export dispatch failed', { err });
242
+ });
243
+ return true;
244
+ }
245
+ if (this._pendingCapture) {
246
+ const id = this._pendingCapture;
247
+ this._pendingCapture = null;
248
+ void ctx.executeCommand?.('incident', ['capture', id]).catch((err) => {
249
+ logger.debug('incident capture dispatch failed', { err });
250
+ });
251
+ return true;
252
+ }
253
+ if (this._pendingJump) {
254
+ const link = this._pendingJump;
255
+ this._pendingJump = null;
256
+ if (link.kind === 'panel') {
257
+ ctx.panelManager.open(link.target);
258
+ } else {
259
+ void ctx.executeCommand?.(link.target, link.args ? link.args.split(/\s+/).filter(Boolean) : []).catch((err) => {
260
+ logger.debug('jump link dispatch failed', { err });
261
+ });
262
+ }
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
82
268
  public render(width: number, height: number): Line[] {
83
- const intro = 'Failure bundles, replay mismatches, permission fallout, and exportable review evidence.';
269
+ if (this._confirm) {
270
+ return buildPanelWorkspace(width, height, {
271
+ title: 'Incident Review Workspace',
272
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this._confirm) }],
273
+ palette: C,
274
+ });
275
+ }
276
+
277
+ const intro = 'Failure bundles with causal chains, phase timings, jump links, and exportable review evidence.';
84
278
 
85
279
  if (!this.registry) {
86
280
  return buildPanelWorkspace(width, height, {
@@ -89,12 +283,9 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
89
283
  sections: [{
90
284
  lines: buildEmptyState(
91
285
  width,
92
- ' Forensics registry not wired into this panel yet.',
93
- 'Incident review needs the live forensics registry so it can inspect failure bundles, replay mismatches, and causal evidence.',
94
- [
95
- { command: '/incident latest', summary: 'inspect the latest incident from the command surface' },
96
- { command: '/security', summary: 'open the broader trust and incident posture control room' },
97
- ],
286
+ ' Incident registry not configured for this session.',
287
+ 'This runtime was not wired with a forensics registry at bootstrap, so no incident data is available.',
288
+ [],
98
289
  C,
99
290
  ),
100
291
  }],
@@ -108,16 +299,21 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
108
299
  }
109
300
 
110
301
  this.clampSelection();
111
- const selected = reports[this.selectedIndex]!;
112
- const bundle = this.registry.buildBundle(selected.id);
302
+ // Header/detail must describe the row the (possibly filtered) list
303
+ // highlights; a filter matching nothing leaves no selection at all.
304
+ const visible = this.getVisibleItems();
305
+ const selected = this.getSelectedItem();
306
+ if (!selected) {
307
+ return this.renderList(width, height, { title: 'Incident Review Workspace' });
308
+ }
309
+ const bundle = this.getBundle(selected.id);
113
310
 
114
311
  const headerLines: Line[] = [
115
312
  buildKeyValueLine(width, [
116
313
  { label: 'incidents', value: String(reports.length), valueColor: C.value },
117
- { label: 'selected', value: `${this.selectedIndex + 1}/${reports.length}`, valueColor: C.info },
314
+ { label: 'selected', value: `${this.selectedIndex + 1}/${visible.length}`, valueColor: C.info },
118
315
  { label: 'classification', value: selected.classification, valueColor: classificationColor(selected.classification) },
119
316
  ], C),
120
- buildPanelLine(width, [[' Up/Down move Home/End jump selected incident drives the action rail below', C.dim]]),
121
317
  ];
122
318
 
123
319
  const footerLines: Line[] = [];
@@ -134,26 +330,21 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
134
330
  ], C));
135
331
  footerLines.push(buildPanelLine(width, [
136
332
  [' Related IDs: ', C.label],
137
- [`turn=${bundle.evidence.relatedIds.turnId ?? 'n/a'} task=${bundle.evidence.relatedIds.taskId ?? 'n/a'} agent=${bundle.evidence.relatedIds.agentId ?? 'n/a'}`.slice(0, Math.max(0, width - 14)), C.info],
333
+ [truncateDisplay(`turn=${bundle.evidence.relatedIds.turnId ?? 'n/a'} task=${bundle.evidence.relatedIds.taskId ?? 'n/a'} agent=${bundle.evidence.relatedIds.agentId ?? 'n/a'}`, Math.max(0, width - 14)), C.info],
138
334
  ]));
139
- if (bundle.evidence.slowPhases.length > 0) {
140
- footerLines.push(buildPanelLine(width, [
141
- [' Slow phases: ', C.label],
142
- ...buildStatusPill('warn', bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15))),
143
- ]));
335
+ if (selected.phaseTimings.length > 0) {
336
+ footerLines.push(buildPanelLine(width, [[' Phase Timings:', C.label]]));
337
+ for (const pt of selected.phaseTimings) this._pushPhaseTimingLine(footerLines, pt, width);
144
338
  }
145
- const rootCause = selected.causalChain.find((entry) => entry.isRootCause);
146
- if (rootCause) {
147
- footerLines.push(buildPanelLine(width, [
148
- [' Root event: ', C.label],
149
- [`${rootCause.sourceEventType} - ${rootCause.description}`.slice(0, Math.max(0, width - 14)), C.dim],
150
- ]));
339
+ if (selected.causalChain.length > 0) {
340
+ footerLines.push(buildPanelLine(width, [[' Causal Chain:', C.label]]));
341
+ for (const entry of selected.causalChain) this._pushCausalLine(footerLines, entry, width);
151
342
  }
152
343
  const denied = selected.permissionEvidence.find((entry) => entry.approved === false);
153
344
  if (denied) {
154
345
  footerLines.push(buildPanelLine(width, [
155
346
  [' Permission: ', C.label],
156
- [`${denied.tool} denied${denied.riskLevel ? ` (${denied.riskLevel})` : ''}${denied.summary ? ` - ${denied.summary}` : ''}`.slice(0, Math.max(0, width - 14)), C.warn],
347
+ [truncateDisplay(`${denied.tool} denied${denied.riskLevel ? ` (${denied.riskLevel})` : ''}${denied.summary ? ` - ${denied.summary}` : ''}`, Math.max(0, width - 14)), C.warn],
157
348
  ]));
158
349
  }
159
350
  if (bundle.replay.relatedMismatches.length > 0) {
@@ -168,7 +359,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
168
359
  : `Replay link: ${mismatch.kind}${mismatch.ownerDomain ? `/${mismatch.ownerDomain}` : ''} - ${mismatch.description}`;
169
360
  footerLines.push(buildPanelLine(width, [
170
361
  [' ', C.label],
171
- ...buildStatusPill('bad', replayDetail.slice(0, Math.max(0, width - 2))),
362
+ ...buildStatusPill('bad', truncateDisplay(replayDetail, Math.max(0, width - 2))),
172
363
  ]));
173
364
  } else {
174
365
  const ownerBreakdown = Object.entries(bundle.replay.mismatchBreakdown.byOwnerDomain)
@@ -179,19 +370,37 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
179
370
  if (ownerBreakdown.length > 0) {
180
371
  footerLines.push(buildPanelLine(width, [
181
372
  [' Replay owners: ', C.label],
182
- [ownerBreakdown.slice(0, Math.max(0, width - 17)), C.info],
373
+ [truncateDisplay(ownerBreakdown, Math.max(0, width - 17)), C.info],
183
374
  ]));
184
375
  }
185
376
  }
377
+ if (selected.jumpLinks.length > 0) {
378
+ footerLines.push(buildPanelLine(width, [[' Jump Links:', C.label]]));
379
+ for (const link of selected.jumpLinks) this._pushJumpLinkLine(footerLines, link, width);
380
+ }
186
381
  }
187
- footerLines.push(buildPanelLine(width, [[' Action Rail', C.label]]));
188
- footerLines.push(buildPanelLine(width, [[` /incident latest /incident export ${selected.id} /recall capture incident ${selected.id}`, C.info]]));
189
382
  footerLines.push(buildGuidanceLine(width, '/security', 'open the broader trust and incident posture control room', C));
190
383
 
384
+ const hints = this.filterActive
385
+ ? [
386
+ { keys: 'type', label: 'filter incidents' },
387
+ { keys: 'Enter', label: 'apply' },
388
+ { keys: 'Esc', label: 'clear' },
389
+ ]
390
+ : [
391
+ { keys: '↑/↓', label: 'select incident' },
392
+ { keys: 'Home/End', label: 'jump' },
393
+ { keys: '/', label: 'filter' },
394
+ { keys: 'x', label: 'export' },
395
+ { keys: 'c', label: 'capture' },
396
+ ...(selected.jumpLinks.length > 0 ? [{ keys: 'j', label: 'follow link' }] : []),
397
+ ];
398
+
191
399
  return this.renderList(width, height, {
192
400
  title: 'Incident Review Workspace',
193
401
  header: headerLines,
194
402
  footer: footerLines,
403
+ hints,
195
404
  });
196
405
  }
197
406
  }