@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.
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +60 -26
- package/src/input/handler-feed.ts +17 -8
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +53 -8
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +117 -1
- package/src/main.ts +5 -2
- package/src/panels/agent-inspector-panel.ts +139 -45
- package/src/panels/agent-inspector-shared.ts +147 -3
- package/src/panels/approval-panel.ts +204 -102
- package/src/panels/automation-control-panel.ts +370 -103
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +253 -75
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +159 -56
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +354 -95
- package/src/panels/cost-tracker-panel.ts +243 -57
- package/src/panels/debug-panel.ts +347 -159
- package/src/panels/diff-panel.ts +257 -82
- package/src/panels/docs-panel.ts +166 -64
- package/src/panels/eval-panel.ts +336 -109
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +238 -159
- package/src/panels/file-preview-panel.ts +141 -59
- package/src/panels/git-panel.ts +360 -191
- package/src/panels/hooks-panel.ts +181 -19
- package/src/panels/incident-review-panel.ts +252 -43
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +310 -103
- package/src/panels/knowledge-graph-panel.ts +480 -58
- package/src/panels/local-auth-panel.ts +251 -33
- package/src/panels/marketplace-panel.ts +219 -33
- package/src/panels/memory-panel.ts +83 -81
- package/src/panels/ops-control-panel.ts +211 -32
- package/src/panels/ops-strategy-panel.ts +131 -45
- package/src/panels/orchestration-panel.ts +259 -67
- package/src/panels/panel-list-panel.ts +184 -136
- package/src/panels/panel-manager.ts +120 -13
- package/src/panels/plan-dashboard-panel.ts +507 -74
- package/src/panels/plugins-panel.ts +227 -34
- package/src/panels/policy-panel.ts +264 -55
- package/src/panels/polish-core.ts +162 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +67 -149
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +84 -113
- package/src/panels/provider-health-panel.ts +438 -480
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +136 -38
- package/src/panels/remote-panel.ts +123 -38
- package/src/panels/routes-panel.ts +87 -24
- package/src/panels/sandbox-panel.ts +232 -65
- package/src/panels/scrollable-list-panel.ts +143 -145
- package/src/panels/security-panel.ts +204 -52
- package/src/panels/services-panel.ts +174 -76
- package/src/panels/session-browser-panel.ts +101 -6
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +346 -68
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +105 -34
- package/src/panels/symbol-outline-panel.ts +287 -154
- package/src/panels/system-messages-panel.ts +172 -38
- package/src/panels/tasks-panel.ts +348 -134
- package/src/panels/thinking-panel.ts +66 -32
- package/src/panels/token-budget-panel.ts +233 -46
- package/src/panels/tool-inspector-panel.ts +170 -54
- package/src/panels/types.ts +65 -4
- package/src/panels/work-plan-panel.ts +393 -39
- package/src/panels/worktree-panel.ts +239 -61
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +146 -150
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +30 -11
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +8 -6
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +45 -36
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -666
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -214
- package/src/panels/forensics-panel.ts +0 -364
- package/src/panels/panel-picker.ts +0 -106
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -218
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/schedule-panel.ts +0 -342
- package/src/panels/watchers-panel.ts +0 -193
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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', '
|
|
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 (
|
|
100
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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, [
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
+
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
26
|
+
import type { PanelIntegrationContext } from './types.ts';
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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', '
|
|
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
|
|
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
|
|
66
|
-
[report.classification
|
|
67
|
-
[report.summary
|
|
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
|
-
|
|
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
|
-
'
|
|
93
|
-
'
|
|
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
|
-
|
|
112
|
-
|
|
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}/${
|
|
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'}
|
|
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 (
|
|
140
|
-
footerLines.push(buildPanelLine(width, [
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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}` : ''}
|
|
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
|
|
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
|
|
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
|
}
|