@pellux/goodvibes-tui 0.18.20 → 0.19.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 (83) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -103,7 +103,7 @@ export class GitPanel extends BasePanel {
103
103
  /** Scroll offset for both main view and diff view. */
104
104
  private scrollOffset = 0;
105
105
 
106
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
106
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
107
107
  private loading = true;
108
108
  private error: string | null = null;
109
109
 
@@ -119,20 +119,21 @@ export class GitPanel extends BasePanel {
119
119
  override onActivate(): void {
120
120
  super.onActivate();
121
121
  void this.refresh();
122
- this.refreshTimer = setInterval(() => {
122
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
123
123
  void this.refresh();
124
- }, 5_000);
124
+ }, 5_000));
125
125
  }
126
126
 
127
127
  override onDeactivate(): void {
128
- if (this.refreshTimer !== null) {
129
- clearInterval(this.refreshTimer);
130
- this.refreshTimer = null;
128
+ if (this.refreshTimerId !== null) {
129
+ this.clearTimer(this.refreshTimerId);
130
+ this.refreshTimerId = null;
131
131
  }
132
132
  }
133
133
 
134
134
  override onDestroy(): void {
135
135
  this.onDeactivate();
136
+ super.onDestroy();
136
137
  }
137
138
 
138
139
  // ---------------------------------------------------------------------------
@@ -326,18 +327,16 @@ export class GitPanel extends BasePanel {
326
327
  const item = this.items[this.selectedIndex];
327
328
  if (!item || item.kind !== 'file') return;
328
329
 
329
- // I3: show base-class spinner while awaiting diff
330
- this.startLoading('Loading diff...');
331
- this.markDirty();
330
+ // I3: withLoading guarantees spinner is cleared even if diffFile throws
332
331
  try {
333
- const git = new GitService(this.workingDirectory);
334
- const raw = await git.diffFile(item.entry.path, item.entry.staged);
335
- this.stopLoading();
332
+ const raw = await this.withLoading('Loading diff…', async () => {
333
+ const git = new GitService(this.workingDirectory);
334
+ return git.diffFile(item.entry.path, item.entry.staged);
335
+ });
336
336
  this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
337
337
  this.scrollOffset = 0;
338
338
  this.markDirty();
339
339
  } catch (err) {
340
- this.stopLoading();
341
340
  this.expandedDiff = [`Error: ${summarizeError(err)}`];
342
341
  this.scrollOffset = 0;
343
342
  this.markDirty();
@@ -1,6 +1,5 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
3
  import { listHookPointContracts } from '@pellux/goodvibes-sdk/platform/hooks/index';
5
4
  import type { HookDispatcher } from '@pellux/goodvibes-sdk/platform/hooks/dispatcher';
6
5
  import type { HookPointContract } from '@pellux/goodvibes-sdk/platform/hooks/contracts';
@@ -10,12 +9,9 @@ import type { HookChain, HookDefinition } from '@pellux/goodvibes-sdk/platform/h
10
9
  import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks/workbench';
11
10
  import { truncateDisplay } from '../utils/terminal-width.ts';
12
11
  import {
13
- buildEmptyState,
14
12
  buildPanelLine,
15
- buildPanelWorkspace,
13
+ buildStatusPill,
16
14
  DEFAULT_PANEL_PALETTE,
17
- resolvePrimaryScrollableSection,
18
- type PanelWorkspaceSection,
19
15
  } from './polish.ts';
20
16
 
21
17
  const C = {
@@ -59,9 +55,9 @@ function createDefaultDataSource(
59
55
  };
60
56
  }
61
57
 
62
- export class HooksPanel extends BasePanel {
63
- private selectedIndex = 0;
64
- private scrollOffset = 0;
58
+ type HookEntry = { pattern: string; hook: HookDefinition };
59
+
60
+ export class HooksPanel extends ScrollableListPanel<HookEntry> {
65
61
  private readonly dataSource: HooksPanelDataSource;
66
62
 
67
63
  public constructor(
@@ -71,34 +67,46 @@ export class HooksPanel extends BasePanel {
71
67
  dataSource: HooksPanelDataSource = createDefaultDataSource(hookDispatcher, hookWorkbench, hookActivityTracker),
72
68
  ) {
73
69
  super('hooks', 'Hooks', 'H', 'monitoring');
70
+ this.showSelectionGutter = true; // I5: non-color selection affordance
74
71
  this.dataSource = dataSource;
75
72
  }
76
73
 
74
+ protected override getPalette() { return C; }
75
+ protected override getEmptyStateMessage() { return ' No hooks are currently registered.'; }
76
+ protected override getEmptyStateActions() {
77
+ return [
78
+ { command: '/hooks', summary: 'review hook contracts and managed authoring actions' },
79
+ { command: '/settings', summary: 'review hook/runtime behavior in the settings surface' },
80
+ ];
81
+ }
82
+
83
+ protected getItems(): readonly HookEntry[] {
84
+ return this.dataSource.listHooks();
85
+ }
86
+
87
+ protected renderItem(entry: HookEntry, index: number, selected: boolean, width: number): Line {
88
+ const bg = selected ? C.selectBg : undefined;
89
+ return buildPanelLine(width, [
90
+ [' ', C.label, bg],
91
+ [truncateDisplay(entry.hook.name ?? '(unnamed)', 20).padEnd(20), C.value, bg],
92
+ [` ${truncateDisplay(entry.pattern, 28).padEnd(28)}`, C.info, bg],
93
+ ...buildStatusPill(entry.hook.enabled === false ? 'warn' : 'good', ` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, { bg }),
94
+ [` ${entry.hook.type}`, C.dim, bg],
95
+ ]);
96
+ }
97
+
77
98
  public handleInput(key: string): boolean {
78
- const entries = this.dataSource.listHooks();
79
99
  if (key === 'r') {
80
100
  this.markDirty();
81
101
  return true;
82
102
  }
83
- if (entries.length === 0) return false;
84
- if (key === 'up' || key === 'k') {
85
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
86
- this.markDirty();
87
- return true;
88
- }
89
- if (key === 'down' || key === 'j') {
90
- this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
91
- this.markDirty();
92
- return true;
93
- }
94
- return false;
103
+ return super.handleInput(key);
95
104
  }
96
105
 
97
106
  public render(width: number, height: number): Line[] {
98
- this.needsRender = false;
99
- const intro = 'Hook contracts, active registrations, managed authoring, recent runtime activity, and simulation matches.';
100
- const contracts = this.dataSource.listContracts();
107
+ this.clampSelection();
101
108
  const hooks = this.dataSource.listHooks();
109
+ const contracts = this.dataSource.listContracts();
102
110
  const chains = this.dataSource.listChains();
103
111
  const recentActivity = this.dataSource.listRecentActivity(3);
104
112
  const workbench = this.dataSource.getWorkbench();
@@ -106,93 +114,48 @@ export class HooksPanel extends BasePanel {
106
114
  const managedChains = workbench.listManagedChains();
107
115
  const recentAuthoring = workbench.listRecentActions(3);
108
116
  const lastSimulation = workbench.getLastSimulation();
117
+ const intro = 'Hook contracts, active registrations, managed authoring, recent runtime activity, and simulation matches.';
109
118
 
110
- if (hooks.length === 0) {
111
- const emptyLines = [
112
- ...buildEmptyState(
113
- width,
114
- ' No hooks are currently registered.',
115
- 'Configure hooks.json or register hooks programmatically, then use this workspace to review contracts, activity, and managed authoring state.',
116
- [
117
- { command: '/hooks', summary: 'review hook contracts and managed authoring actions' },
118
- { command: '/settings', summary: 'review hook/runtime behavior in the settings surface' },
119
- ],
120
- C,
121
- ),
122
- buildPanelLine(width, [
123
- [' Contracts: ', C.label],
124
- [String(contracts.length), C.value],
125
- [' Chains: ', C.label],
126
- [String(chains.length), C.value],
127
- [' Managed: ', C.label],
128
- [String(managedHooks.length), C.info],
129
- ]),
130
- buildPanelLine(width, [
131
- [' Hooks file: ', C.label],
132
- [truncateDisplay(workbench.getHooksFilePath(), Math.max(0, width - 15)), C.dim],
133
- ]),
134
- ];
135
- if (recentAuthoring.length > 0) {
136
- emptyLines.push(buildPanelLine(width, [
137
- [' Authoring: ', C.label],
138
- [truncateDisplay(`${recentAuthoring[0]!.kind} ${recentAuthoring[0]!.target}`, Math.max(0, width - 14)), C.info],
139
- ]));
140
- }
141
- if (lastSimulation) {
142
- emptyLines.push(buildPanelLine(width, [
143
- [' Last Simulation: ', C.label],
144
- [truncateDisplay(lastSimulation.eventPath, Math.max(0, width - 20)), C.value],
145
- ]));
146
- }
147
- const workspace = buildPanelWorkspace(width, height, {
148
- title: 'Hooks Control Room',
149
- intro,
150
- sections: [{ lines: emptyLines }],
151
- palette: C,
152
- });
153
- while (workspace.length < height) workspace.push(createEmptyLine(width));
154
- return workspace;
155
- }
119
+ const selected = hooks[this.selectedIndex];
120
+ const contract = selected ? contracts.find((c) => c.pattern === selected.pattern) : undefined;
156
121
 
157
- this.selectedIndex = Math.min(this.selectedIndex, hooks.length - 1);
158
- const selected = hooks[this.selectedIndex]!;
159
- const contract = contracts.find((candidate) => candidate.pattern === selected.pattern);
160
- const detailLines: Line[] = [
161
- buildPanelLine(width, [
122
+ const detailLines: Line[] = [];
123
+ if (selected) {
124
+ detailLines.push(buildPanelLine(width, [
162
125
  [' Hook: ', C.label],
163
126
  [selected.hook.name ?? '(unnamed)', C.value],
164
127
  [' Type: ', C.label],
165
128
  [selected.hook.type, C.info],
166
129
  [' Match: ', C.label],
167
130
  [selected.hook.matcher ?? selected.hook.match, C.value],
168
- ]),
169
- buildPanelLine(width, [
131
+ ]));
132
+ detailLines.push(buildPanelLine(width, [
170
133
  [' Pattern: ', C.label],
171
134
  [truncateDisplay(selected.pattern, Math.max(0, width - 12)), C.value],
172
- ]),
173
- ];
174
- if (contract) {
135
+ ]));
136
+ if (contract) {
137
+ detailLines.push(buildPanelLine(width, [
138
+ [' Contract: ', C.label],
139
+ [`${contract.authority} / ${contract.executionMode}`, C.info],
140
+ [' Policy: ', C.label],
141
+ [contract.failurePolicy, C.value],
142
+ ]));
143
+ detailLines.push(buildPanelLine(width, [
144
+ [' Capabilities: ', C.label],
145
+ [`deny=${contract.canDeny ? 'yes' : 'no'} mutate=${contract.canMutateInput ? 'yes' : 'no'} inject=${contract.canInjectContext ? 'yes' : 'no'}`, C.dim],
146
+ ]));
147
+ } else {
148
+ detailLines.push(buildPanelLine(width, [[' Contract: No exact contract registered for this pattern.', C.warn]]));
149
+ }
175
150
  detailLines.push(buildPanelLine(width, [
176
- [' Contract: ', C.label],
177
- [`${contract.authority} / ${contract.executionMode}`, C.info],
178
- [' Policy: ', C.label],
179
- [contract.failurePolicy, C.value],
151
+ [' Summary: ', C.label],
152
+ [`hooks=${hooks.length} chains=${chains.length} contracts=${contracts.length} managed=${managedHooks.length}/${managedChains.length}`, C.dim],
180
153
  ]));
181
154
  detailLines.push(buildPanelLine(width, [
182
- [' Capabilities: ', C.label],
183
- [`deny=${contract.canDeny ? 'yes' : 'no'} mutate=${contract.canMutateInput ? 'yes' : 'no'} inject=${contract.canInjectContext ? 'yes' : 'no'}`, C.dim],
155
+ [' Hooks file: ', C.label],
156
+ [truncateDisplay(workbench.getHooksFilePath(), Math.max(0, width - 15)), C.dim],
184
157
  ]));
185
- } else {
186
- detailLines.push(buildPanelLine(width, [[' Contract: No exact contract registered for this pattern.', C.warn]]));
187
158
  }
188
- detailLines.push(buildPanelLine(width, [
189
- [' Summary: ', C.label],
190
- [`hooks=${hooks.length} chains=${chains.length} contracts=${contracts.length} managed=${managedHooks.length}/${managedChains.length}`, C.dim],
191
- ]));
192
- detailLines.push(buildPanelLine(width, [
193
- [' Hooks file: ', C.label],
194
- [truncateDisplay(workbench.getHooksFilePath(), Math.max(0, width - 15)), C.dim],
195
- ]));
196
159
 
197
160
  const activityLines: Line[] = recentActivity.length === 0
198
161
  ? [buildPanelLine(width, [[' No hook activity recorded yet.', C.empty]])]
@@ -226,49 +189,51 @@ export class HooksPanel extends BasePanel {
226
189
  [`hooks=${lastSimulation.matchedHooks.length} chains=${lastSimulation.matchedChains.length}`, C.dim],
227
190
  ]));
228
191
  }
229
- const selectedSection: PanelWorkspaceSection = { title: 'Selected Hook', lines: detailLines };
230
- const activitySection: PanelWorkspaceSection = { title: 'Recent Activity', lines: activityLines };
231
- const authoringSection: PanelWorkspaceSection = { title: 'Authoring', lines: authoringLines };
232
- const resolvedHooksSection = resolvePrimaryScrollableSection(width, height, {
233
- intro,
234
- footerLines: [buildPanelLine(width, [[' Up/Down move r refresh /hooks for full contract listing', C.dim]])],
235
- palette: C,
236
- section: {
237
- title: 'Hooks',
238
- scrollableLines: hooks.map((entry, absolute) => {
239
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
240
- return buildPanelLine(width, [
241
- [' ', C.label, bg],
242
- [truncateDisplay(entry.hook.name ?? '(unnamed)', 20).padEnd(20), C.value, bg],
243
- [` ${truncateDisplay(entry.pattern, 28).padEnd(28)}`, C.info, bg],
244
- [` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, entry.hook.enabled === false ? C.warn : C.ok, bg],
245
- [` ${entry.hook.type}`, C.dim, bg],
246
- ]);
247
- }),
248
- selectedIndex: this.selectedIndex,
249
- scrollOffset: this.scrollOffset,
250
- guardRows: 1,
251
- minRows: 4,
252
- appendWindowSummary: { dimColor: C.dim },
253
- },
254
- afterSections: [selectedSection, activitySection, authoringSection],
255
- });
256
- this.scrollOffset = resolvedHooksSection.scrollOffset;
257
192
 
258
- const sections: PanelWorkspaceSection[] = [
259
- resolvedHooksSection.section,
260
- selectedSection,
261
- activitySection,
262
- authoringSection,
263
- ];
264
- const lines = buildPanelWorkspace(width, height, {
193
+ // Empty state: show extra context lines (hooks file, contracts, authoring) before base empty state
194
+ if (hooks.length === 0) {
195
+ const extraHeader: Line[] = [
196
+ buildPanelLine(width, [
197
+ [' Contracts: ', C.label],
198
+ [String(contracts.length), C.value],
199
+ [' Chains: ', C.label],
200
+ [String(chains.length), C.value],
201
+ [' Managed: ', C.label],
202
+ [String(managedHooks.length), C.info],
203
+ ]),
204
+ buildPanelLine(width, [
205
+ [' Hooks file: ', C.label],
206
+ [truncateDisplay(workbench.getHooksFilePath(), Math.max(0, width - 15)), C.dim],
207
+ ]),
208
+ ];
209
+ if (recentAuthoring.length > 0) {
210
+ extraHeader.push(buildPanelLine(width, [
211
+ [' Authoring: ', C.label],
212
+ [truncateDisplay(`${recentAuthoring[0]!.kind} ${recentAuthoring[0]!.target}`, Math.max(0, width - 14)), C.info],
213
+ ]));
214
+ }
215
+ if (lastSimulation) {
216
+ extraHeader.push(buildPanelLine(width, [
217
+ [' Last Simulation: ', C.label],
218
+ [truncateDisplay(lastSimulation.eventPath, Math.max(0, width - 20)), C.value],
219
+ ]));
220
+ }
221
+ return this.renderList(width, height, {
222
+ title: 'Hooks Control Room',
223
+ header: extraHeader,
224
+ });
225
+ }
226
+
227
+ return this.renderList(width, height, {
265
228
  title: 'Hooks Control Room',
266
- intro,
267
- sections,
268
- footerLines: [buildPanelLine(width, [[' Up/Down move r refresh /hooks for full contract listing', C.dim]])],
269
- palette: C,
229
+ footer: [
230
+ ...detailLines,
231
+ buildPanelLine(width, [[' Recent Activity', C.label]]),
232
+ ...activityLines,
233
+ buildPanelLine(width, [[' Authoring', C.label]]),
234
+ ...authoringLines,
235
+ buildPanelLine(width, [[' Up/Down move r refresh /hooks for full contract listing', C.dim]]),
236
+ ],
270
237
  });
271
- while (lines.length < height) lines.push(createEmptyLine(width));
272
- return lines.slice(0, height);
273
238
  }
274
239
  }
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import type { ForensicsRegistry } from '@pellux/goodvibes-sdk/platform/runtime/forensics/registry';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import {
5
5
  buildBodyText,
6
6
  buildEmptyState,
@@ -8,10 +8,11 @@ import {
8
8
  buildKeyValueLine,
9
9
  buildPanelLine,
10
10
  buildPanelWorkspace,
11
- resolveScrollablePanelSection,
11
+ buildStatusPill,
12
12
  DEFAULT_PANEL_PALETTE,
13
- type PanelWorkspaceSection,
13
+ type PanelPalette,
14
14
  } from './polish.ts';
15
+ import type { FailureReport } from '@pellux/goodvibes-sdk/platform/runtime/forensics/types';
15
16
 
16
17
  const C = {
17
18
  ...DEFAULT_PANEL_PALETTE,
@@ -34,14 +35,13 @@ function classificationColor(value: string): string {
34
35
  }
35
36
  }
36
37
 
37
- export class IncidentReviewPanel extends BasePanel {
38
+ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
38
39
  private readonly registry?: ForensicsRegistry;
39
40
  private readonly unsub: (() => void) | null;
40
- private selectedIndex = 0;
41
- private scrollOffset = 0;
42
41
 
43
42
  public constructor(registry?: ForensicsRegistry) {
44
43
  super('incident', 'Incident Review', 'N', 'monitoring');
44
+ this.showSelectionGutter = true; // I5: non-color selection affordance
45
45
  this.registry = registry;
46
46
  this.unsub = registry ? registry.subscribe(() => this.markDirty()) : null;
47
47
  }
@@ -50,34 +50,36 @@ export class IncidentReviewPanel extends BasePanel {
50
50
  this.unsub?.();
51
51
  }
52
52
 
53
- public handleInput(key: string): boolean {
54
- const reports = this.registry?.getAll() ?? [];
55
- if (reports.length === 0) return false;
56
- if (key === 'up' || key === 'k') {
57
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
58
- this.markDirty();
59
- return true;
60
- }
61
- if (key === 'down' || key === 'j') {
62
- this.selectedIndex = Math.min(reports.length - 1, this.selectedIndex + 1);
63
- this.markDirty();
64
- return true;
65
- }
66
- if (key === 'home') {
67
- this.selectedIndex = 0;
68
- this.markDirty();
69
- return true;
70
- }
71
- if (key === 'end') {
72
- this.selectedIndex = reports.length - 1;
73
- this.markDirty();
74
- return true;
75
- }
76
- return false;
53
+ protected override getPalette(): PanelPalette {
54
+ return C;
55
+ }
56
+
57
+ protected getItems(): readonly FailureReport[] {
58
+ return this.registry?.getAll() ?? [];
59
+ }
60
+
61
+ protected renderItem(report: FailureReport, index: number, selected: boolean, width: number): Line {
62
+ const bg = selected ? C.selectBg : undefined;
63
+ return buildPanelLine(width, [
64
+ [' ', 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],
68
+ ]);
69
+ }
70
+
71
+ protected override getEmptyStateMessage(): string {
72
+ return ' No incidents recorded yet.';
73
+ }
74
+
75
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
76
+ return [
77
+ { command: '/incident latest', summary: 'inspect the latest report once one exists' },
78
+ { command: '/recall capture incident latest', summary: 'promote incident evidence into project knowledge' },
79
+ ];
77
80
  }
78
81
 
79
82
  public render(width: number, height: number): Line[] {
80
- this.needsRender = false;
81
83
  const intro = 'Failure bundles, replay mismatches, permission fallout, and exportable review evidence.';
82
84
 
83
85
  if (!this.registry) {
@@ -100,32 +102,16 @@ export class IncidentReviewPanel extends BasePanel {
100
102
  });
101
103
  }
102
104
 
103
- const reports = this.registry.getAll();
105
+ const reports = this.getItems();
104
106
  if (reports.length === 0) {
105
- return buildPanelWorkspace(width, height, {
106
- title: 'Incident Review Workspace',
107
- intro,
108
- sections: [{
109
- lines: buildEmptyState(
110
- width,
111
- ' No incidents recorded yet.',
112
- 'The incident workspace fills automatically when failures produce forensics reports, replay mismatches, or policy-linked fallout.',
113
- [
114
- { command: '/incident latest', summary: 'inspect the latest report once one exists' },
115
- { command: '/recall capture incident latest', summary: 'promote incident evidence into project knowledge' },
116
- ],
117
- C,
118
- ),
119
- }],
120
- palette: C,
121
- });
107
+ return this.renderList(width, height, { title: 'Incident Review Workspace' });
122
108
  }
123
109
 
124
- this.selectedIndex = Math.min(this.selectedIndex, reports.length - 1);
110
+ this.clampSelection();
125
111
  const selected = reports[this.selectedIndex]!;
126
112
  const bundle = this.registry.buildBundle(selected.id);
127
113
 
128
- const summaryLines = [
114
+ const headerLines: Line[] = [
129
115
  buildKeyValueLine(width, [
130
116
  { label: 'incidents', value: String(reports.length), valueColor: C.value },
131
117
  { label: 'selected', value: `${this.selectedIndex + 1}/${reports.length}`, valueColor: C.info },
@@ -134,38 +120,38 @@ export class IncidentReviewPanel extends BasePanel {
134
120
  buildPanelLine(width, [[' Up/Down move Home/End jump selected incident drives the action rail below', C.dim]]),
135
121
  ];
136
122
 
137
- const selectedLines: Line[] = [];
123
+ const footerLines: Line[] = [];
138
124
  if (bundle) {
139
- selectedLines.push(buildKeyValueLine(width, [
125
+ footerLines.push(buildKeyValueLine(width, [
140
126
  { label: 'id', value: selected.id, valueColor: C.dim },
141
127
  { label: 'trace', value: selected.traceId, valueColor: C.dim },
142
128
  ], C));
143
- selectedLines.push(...buildBodyText(width, `Root cause: ${bundle.evidence.rootCause ?? 'n/a'}`, C, C.value));
144
- selectedLines.push(buildKeyValueLine(width, [
129
+ footerLines.push(...buildBodyText(width, `Root cause: ${bundle.evidence.rootCause ?? 'n/a'}`, C, C.value));
130
+ footerLines.push(buildKeyValueLine(width, [
145
131
  { label: 'Permissions denied', value: String(bundle.evidence.deniedPermissionCount), valueColor: bundle.evidence.deniedPermissionCount > 0 ? C.warn : C.dim },
146
132
  { label: 'Budget breaches', value: String(bundle.evidence.budgetBreachCount), valueColor: bundle.evidence.budgetBreachCount > 0 ? C.warn : C.dim },
147
133
  { label: 'Replay mismatches', value: String(bundle.replay.mismatchCount), valueColor: bundle.replay.mismatchCount > 0 ? C.bad : C.dim },
148
134
  ], C));
149
- selectedLines.push(buildPanelLine(width, [
135
+ footerLines.push(buildPanelLine(width, [
150
136
  [' Related IDs: ', C.label],
151
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],
152
138
  ]));
153
139
  if (bundle.evidence.slowPhases.length > 0) {
154
- selectedLines.push(buildPanelLine(width, [
140
+ footerLines.push(buildPanelLine(width, [
155
141
  [' Slow phases: ', C.label],
156
- [bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15)), C.warn],
142
+ ...buildStatusPill('warn', bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15))),
157
143
  ]));
158
144
  }
159
145
  const rootCause = selected.causalChain.find((entry) => entry.isRootCause);
160
146
  if (rootCause) {
161
- selectedLines.push(buildPanelLine(width, [
147
+ footerLines.push(buildPanelLine(width, [
162
148
  [' Root event: ', C.label],
163
149
  [`${rootCause.sourceEventType} - ${rootCause.description}`.slice(0, Math.max(0, width - 14)), C.dim],
164
150
  ]));
165
151
  }
166
152
  const denied = selected.permissionEvidence.find((entry) => entry.approved === false);
167
153
  if (denied) {
168
- selectedLines.push(buildPanelLine(width, [
154
+ footerLines.push(buildPanelLine(width, [
169
155
  [' Permission: ', C.label],
170
156
  [`${denied.tool} denied${denied.riskLevel ? ` (${denied.riskLevel})` : ''}${denied.summary ? ` - ${denied.summary}` : ''}`.slice(0, Math.max(0, width - 14)), C.warn],
171
157
  ]));
@@ -180,68 +166,32 @@ export class IncidentReviewPanel extends BasePanel {
180
166
  const replayDetail = ownerBreakdown.length > 0
181
167
  ? `Replay link: ${mismatch.kind}${mismatch.ownerDomain ? `/${mismatch.ownerDomain}` : ''} - ${mismatch.description} Replay owners: ${ownerBreakdown}`
182
168
  : `Replay link: ${mismatch.kind}${mismatch.ownerDomain ? `/${mismatch.ownerDomain}` : ''} - ${mismatch.description}`;
183
- selectedLines.push(buildPanelLine(width, [
169
+ footerLines.push(buildPanelLine(width, [
184
170
  [' ', C.label],
185
- [replayDetail.slice(0, Math.max(0, width - 2)), C.bad],
171
+ ...buildStatusPill('bad', replayDetail.slice(0, Math.max(0, width - 2))),
186
172
  ]));
187
173
  } else {
188
174
  const ownerBreakdown = Object.entries(bundle.replay.mismatchBreakdown.byOwnerDomain)
189
- .filter(([, count]) => count > 0)
190
- .slice(0, 3)
191
- .map(([domain, count]) => `${domain}:${count}`)
192
- .join(', ');
175
+ .filter(([, count]) => count > 0)
176
+ .slice(0, 3)
177
+ .map(([domain, count]) => `${domain}:${count}`)
178
+ .join(', ');
193
179
  if (ownerBreakdown.length > 0) {
194
- selectedLines.push(buildPanelLine(width, [
180
+ footerLines.push(buildPanelLine(width, [
195
181
  [' Replay owners: ', C.label],
196
182
  [ownerBreakdown.slice(0, Math.max(0, width - 17)), C.info],
197
183
  ]));
198
184
  }
199
185
  }
200
186
  }
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
+ footerLines.push(buildGuidanceLine(width, '/security', 'open the broader trust and incident posture control room', C));
201
190
 
202
- const actionLines = [
203
- buildPanelLine(width, [[` /incident latest /incident export ${selected.id} /recall capture incident ${selected.id}`, C.info]]),
204
- ];
205
-
206
- const summarySection: PanelWorkspaceSection = { title: 'Summary', lines: summaryLines };
207
- const actionSection: PanelWorkspaceSection = { title: 'Action Rail', lines: actionLines };
208
- const selectedIncidentSection: PanelWorkspaceSection = { title: 'Selected Incident', lines: selectedLines };
209
- const incidentsSection = resolveScrollablePanelSection(width, height, {
210
- intro,
211
- palette: C,
212
- beforeSections: [summarySection],
213
- section: {
214
- title: 'Incidents',
215
- scrollableLines: reports.map((report, globalIndex) => {
216
- const bg = globalIndex === this.selectedIndex ? C.selectBg : undefined;
217
- return buildPanelLine(width, [
218
- [' ', C.label, bg],
219
- [report.id.slice(0, 8).padEnd(9), C.dim, bg],
220
- [report.classification.padEnd(20), classificationColor(report.classification), bg],
221
- [report.summary.slice(0, Math.max(0, width - 31)), C.value, bg],
222
- ]);
223
- }),
224
- selectedIndex: this.selectedIndex,
225
- scrollOffset: this.scrollOffset,
226
- minRows: 4,
227
- appendWindowSummary: { dimColor: C.dim },
228
- },
229
- afterSections: [actionSection, selectedIncidentSection],
230
- });
231
- this.scrollOffset = incidentsSection.scrollOffset;
232
-
233
- const sections: PanelWorkspaceSection[] = [
234
- summarySection,
235
- incidentsSection.section,
236
- actionSection,
237
- selectedIncidentSection,
238
- ];
239
-
240
- return buildPanelWorkspace(width, height, {
191
+ return this.renderList(width, height, {
241
192
  title: 'Incident Review Workspace',
242
- intro,
243
- sections,
244
- palette: C,
193
+ header: headerLines,
194
+ footer: footerLines,
245
195
  });
246
196
  }
247
197
  }