@pellux/goodvibes-tui 0.28.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 +101 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +5 -1
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- 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 +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +203 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +309 -128
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- 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 +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- 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 +21 -7
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +5 -4
- 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/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +41 -30
- 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/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- 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 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
|
@@ -6,35 +6,29 @@ import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.
|
|
|
6
6
|
import {
|
|
7
7
|
buildAlignedRow,
|
|
8
8
|
buildEmptyState,
|
|
9
|
-
|
|
9
|
+
buildKeyboardHints,
|
|
10
10
|
buildPanelLine,
|
|
11
11
|
buildPanelWorkspace,
|
|
12
12
|
buildStatusPill,
|
|
13
13
|
DEFAULT_PANEL_PALETTE,
|
|
14
14
|
} from './polish.ts';
|
|
15
15
|
import { createEmptyLine } from '../types/grid.ts';
|
|
16
|
+
import type { PanelIntegrationContext } from './types.ts';
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
headerBg: '#1e293b',
|
|
21
|
-
dim: '#475569',
|
|
22
|
-
ok: '#22c55e',
|
|
23
|
-
warn: '#eab308',
|
|
24
|
-
error: '#ef4444',
|
|
25
|
-
selectBg: '#0f172a',
|
|
26
|
-
} as const;
|
|
18
|
+
// Base chrome only — title band, state colors, and text tokens all come
|
|
19
|
+
// straight from DEFAULT_PANEL_PALETTE (WO-002).
|
|
20
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
27
21
|
|
|
28
22
|
function managedColor(managed: boolean): string {
|
|
29
23
|
return managed ? C.warn : C.info;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
function resultColor(result: TokenAuditResult): string {
|
|
33
|
-
if (result.blocked) return C.
|
|
34
|
-
if (result.scope.outcome === 'violation') return C.
|
|
35
|
-
if (result.rotation.outcome === 'overdue') return C.
|
|
27
|
+
if (result.blocked) return C.bad;
|
|
28
|
+
if (result.scope.outcome === 'violation') return C.bad;
|
|
29
|
+
if (result.rotation.outcome === 'overdue') return C.bad;
|
|
36
30
|
if (result.rotation.outcome === 'warning') return C.warn;
|
|
37
|
-
return C.
|
|
31
|
+
return C.good;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
34
|
function resultSummary(result: TokenAuditResult): string {
|
|
@@ -50,20 +44,55 @@ function severityColor(severity: 'low' | 'medium' | 'high' | 'critical'): string
|
|
|
50
44
|
switch (severity) {
|
|
51
45
|
case 'critical':
|
|
52
46
|
case 'high':
|
|
53
|
-
return C.
|
|
47
|
+
return C.bad;
|
|
54
48
|
case 'medium':
|
|
55
49
|
return C.warn;
|
|
56
50
|
case 'low':
|
|
57
51
|
default:
|
|
58
|
-
return C.
|
|
52
|
+
return C.good;
|
|
59
53
|
}
|
|
60
54
|
}
|
|
61
55
|
|
|
56
|
+
// Relative-time formatting for audit/rotation timestamps — humanized instead
|
|
57
|
+
// of raw ISO strings (WO-137). Mirrors the fmtAgo pattern already used by
|
|
58
|
+
// approval-panel.ts / debug-panel.ts / communication-panel.ts.
|
|
59
|
+
function fmtAgo(ts: number): string {
|
|
60
|
+
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
61
|
+
if (sec < 5) return 'just now';
|
|
62
|
+
if (sec < 60) return `${sec}s ago`;
|
|
63
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
64
|
+
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
|
65
|
+
return `${Math.floor(sec / 86400)}d ago`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fmtDue(msUntilDue: number): string {
|
|
69
|
+
const overdue = msUntilDue < 0;
|
|
70
|
+
const sec = Math.floor(Math.abs(msUntilDue) / 1000);
|
|
71
|
+
const unit = sec < 3600
|
|
72
|
+
? `${Math.max(1, Math.floor(sec / 60))}m`
|
|
73
|
+
: sec < 86400
|
|
74
|
+
? `${Math.floor(sec / 3600)}h`
|
|
75
|
+
: `${Math.floor(sec / 86400)}d`;
|
|
76
|
+
return overdue ? `overdue by ${unit}` : `in ${unit}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Attack-path finding rows: 3 rendered lines each (severity/route, reason,
|
|
80
|
+
// evidence). Bounds the per-render window to the panel's actual height
|
|
81
|
+
// instead of a fixed "3 findings" cap, and pages through the rest via
|
|
82
|
+
// attackPathScroll ('[' / ']').
|
|
83
|
+
const ATTACK_PATH_FINDING_LINES = 3;
|
|
84
|
+
|
|
62
85
|
export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
63
86
|
private readonly unsub: (() => void) | null;
|
|
87
|
+
/** Scroll offset into attackPathReview.findings (in finding units, not lines). */
|
|
88
|
+
private attackPathScroll = 0;
|
|
89
|
+
/** Set by 'f'; consumed by handlePanelIntegrationAction to dispatch /policy preflight. */
|
|
90
|
+
private pendingPreflight = false;
|
|
91
|
+
/** Set by 'i'; consumed by handlePanelIntegrationAction to jump to the incident panel. */
|
|
92
|
+
private pendingIncidentJump = false;
|
|
64
93
|
|
|
65
94
|
public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
|
|
66
|
-
super('security', 'Security', '
|
|
95
|
+
super('security', 'Security', '▬', 'security-policy');
|
|
67
96
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
68
97
|
this.filterEnabled = true;
|
|
69
98
|
this.filterLabel = 'Filter tokens';
|
|
@@ -77,9 +106,13 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
77
106
|
protected override getPalette() { return C; }
|
|
78
107
|
protected override getEmptyStateMessage() { return ' No API tokens are registered with the security auditor yet.'; }
|
|
79
108
|
protected override getEmptyStateActions() {
|
|
109
|
+
// WO-160: '/policy preflight' dropped — 'f' already dispatches it for
|
|
110
|
+
// real (see handleInput/handlePanelIntegrationAction) and is advertised
|
|
111
|
+
// as a live key hint in the footer even in this empty state, so listing
|
|
112
|
+
// it here too was a redundant action substitute. '/storage review' and
|
|
113
|
+
// '/mcp trust' stay: neither has an in-panel key equivalent.
|
|
80
114
|
return [
|
|
81
115
|
{ command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
|
|
82
|
-
{ command: '/policy preflight', summary: 'run a live preflight posture review' },
|
|
83
116
|
{ command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
|
|
84
117
|
];
|
|
85
118
|
}
|
|
@@ -109,12 +142,58 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
109
142
|
|
|
110
143
|
public handleInput(key: string): boolean {
|
|
111
144
|
if (!this.filterActive && key === 'r') {
|
|
145
|
+
// Real re-audit: force the read model to recompute (ApiTokenAuditor.auditAll
|
|
146
|
+
// under the hood) right now instead of waiting on the next incidental
|
|
147
|
+
// render, so lastAuditAt visibly advances the instant 'r' is pressed.
|
|
148
|
+
this.readModel.getSnapshot();
|
|
149
|
+
this.markDirty();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (!this.filterActive && key === 'f') {
|
|
153
|
+
this.pendingPreflight = true;
|
|
154
|
+
this.markDirty();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (!this.filterActive && key === 'i') {
|
|
158
|
+
if (!this.readModel.getSnapshot().latestIncident) return false;
|
|
159
|
+
this.pendingIncidentJump = true;
|
|
160
|
+
this.markDirty();
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (!this.filterActive && key === '[') {
|
|
164
|
+
this.attackPathScroll = Math.max(0, this.attackPathScroll - 1);
|
|
165
|
+
this.markDirty();
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (!this.filterActive && key === ']') {
|
|
169
|
+
this.attackPathScroll += 1;
|
|
112
170
|
this.markDirty();
|
|
113
171
|
return true;
|
|
114
172
|
}
|
|
115
173
|
return super.handleInput(key);
|
|
116
174
|
}
|
|
117
175
|
|
|
176
|
+
/**
|
|
177
|
+
* f (preflight) and i (jump to incident) both require the integration
|
|
178
|
+
* context (executeCommand / panelManager), which is only available here —
|
|
179
|
+
* same staged-pending-action pattern as worktree-panel.ts and
|
|
180
|
+
* incident-review-panel.ts.
|
|
181
|
+
*/
|
|
182
|
+
public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
|
|
183
|
+
if (this.pendingPreflight) {
|
|
184
|
+
this.pendingPreflight = false;
|
|
185
|
+
if (!ctx.executeCommand) return false;
|
|
186
|
+
void ctx.executeCommand('policy', ['preflight']).catch(() => { /* surfaced via /policy output */ });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (this.pendingIncidentJump) {
|
|
190
|
+
this.pendingIncidentJump = false;
|
|
191
|
+
ctx.panelManager.open('incident');
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
118
197
|
protected override filterMatches(result: TokenAuditResult, q: string): boolean {
|
|
119
198
|
return result.label.toLowerCase().includes(q)
|
|
120
199
|
|| result.scope.policyId.toLowerCase().includes(q)
|
|
@@ -136,7 +215,10 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
136
215
|
const untrustedPlugins = snapshot.untrustedPlugins;
|
|
137
216
|
const attackPathReview = snapshot.attackPathReview;
|
|
138
217
|
const intro = 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.';
|
|
139
|
-
|
|
218
|
+
// WO-160: dropped the printed '/policy preflight' guidance line — 'f' is
|
|
219
|
+
// already bound to dispatch it for real (see handleInput/
|
|
220
|
+
// handlePanelIntegrationAction below) and is advertised in the keyboard
|
|
221
|
+
// hints footer, so the printed command was a pure action substitute.
|
|
140
222
|
|
|
141
223
|
const governanceLines: Line[] = [
|
|
142
224
|
buildPanelLine(width, [
|
|
@@ -212,7 +294,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
212
294
|
{ title: 'Governance', lines: emptyStateLines },
|
|
213
295
|
{ title: 'Attack Paths', lines: attackPathLines },
|
|
214
296
|
],
|
|
215
|
-
footerLines: [
|
|
297
|
+
footerLines: [buildKeyboardHints(width, [{ keys: 'f', label: 'preflight' }], C)],
|
|
216
298
|
palette: C,
|
|
217
299
|
});
|
|
218
300
|
while (workspace.length < height) workspace.push(createEmptyLine(width));
|
|
@@ -221,7 +303,15 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
221
303
|
|
|
222
304
|
if (attackPathReview.findings.length > 0) {
|
|
223
305
|
attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
|
|
224
|
-
|
|
306
|
+
// Fully scrollable — no fixed "3 findings" cap. The visible window
|
|
307
|
+
// scales with the panel's actual height; '[' / ']' page through the
|
|
308
|
+
// rest when there are more findings than currently fit.
|
|
309
|
+
const windowSize = Math.max(1, Math.floor((height - 16) / ATTACK_PATH_FINDING_LINES));
|
|
310
|
+
const maxScroll = Math.max(0, attackPathReview.findings.length - windowSize);
|
|
311
|
+
this.attackPathScroll = Math.min(this.attackPathScroll, maxScroll);
|
|
312
|
+
const start = this.attackPathScroll;
|
|
313
|
+
const end = Math.min(attackPathReview.findings.length, start + windowSize);
|
|
314
|
+
for (const finding of attackPathReview.findings.slice(start, end)) {
|
|
225
315
|
attackPathLines.push(buildPanelLine(width, [[
|
|
226
316
|
truncateDisplay(` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`, width),
|
|
227
317
|
severityColor(finding.severity),
|
|
@@ -235,6 +325,12 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
235
325
|
C.dim,
|
|
236
326
|
]]));
|
|
237
327
|
}
|
|
328
|
+
if (attackPathReview.findings.length > windowSize) {
|
|
329
|
+
attackPathLines.push(buildPanelLine(width, [[
|
|
330
|
+
` showing ${start + 1}-${end} of ${attackPathReview.findings.length} findings ([ / ] to scroll)`,
|
|
331
|
+
C.dim,
|
|
332
|
+
]]));
|
|
333
|
+
}
|
|
238
334
|
}
|
|
239
335
|
|
|
240
336
|
// Column header for the token audit list so the aligned columns are legible.
|
|
@@ -258,7 +354,9 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
258
354
|
),
|
|
259
355
|
];
|
|
260
356
|
|
|
261
|
-
|
|
357
|
+
// Detail must track the filtered view the list highlights, not the raw
|
|
358
|
+
// audit results — under an applied token filter they diverge.
|
|
359
|
+
const selected = this.getSelectedItem();
|
|
262
360
|
const detailLines: Line[] = [];
|
|
263
361
|
if (selected) {
|
|
264
362
|
detailLines.push(buildPanelLine(width, [
|
|
@@ -267,44 +365,44 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
267
365
|
[' Policy: ', C.label],
|
|
268
366
|
[selected.scope.policyId, C.info],
|
|
269
367
|
[' Blocked: ', C.label],
|
|
270
|
-
[selected.blocked ? 'yes' : 'no', selected.blocked ? C.
|
|
368
|
+
[selected.blocked ? 'yes' : 'no', selected.blocked ? C.bad : C.good],
|
|
271
369
|
]));
|
|
272
370
|
detailLines.push(buildPanelLine(width, [
|
|
273
371
|
[' Scope: ', C.label],
|
|
274
|
-
[selected.scope.outcome, selected.scope.outcome === 'violation' ? C.
|
|
372
|
+
[selected.scope.outcome, selected.scope.outcome === 'violation' ? C.bad : C.good],
|
|
275
373
|
[' Excess: ', C.label],
|
|
276
|
-
[truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.
|
|
374
|
+
[truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.bad : C.dim],
|
|
277
375
|
]));
|
|
278
376
|
detailLines.push(buildPanelLine(width, [
|
|
279
377
|
[' Rotation: ', C.label],
|
|
280
|
-
[selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.
|
|
378
|
+
[selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.good : selected.rotation.outcome === 'warning' ? C.warn : C.bad],
|
|
281
379
|
[' Due: ', C.label],
|
|
282
|
-
[
|
|
380
|
+
[fmtDue(selected.rotation.msUntilDue), selected.rotation.msUntilDue < 0 ? C.bad : C.value],
|
|
283
381
|
[' Age(d): ', C.label],
|
|
284
382
|
[String(Math.floor(selected.rotation.ageMs / (24 * 60 * 60 * 1000))), C.value],
|
|
285
383
|
]));
|
|
286
384
|
detailLines.push(buildPanelLine(width, [[
|
|
287
|
-
`Last audit: ${view.lastAuditAt ?
|
|
385
|
+
`Last audit: ${view.lastAuditAt ? fmtAgo(view.lastAuditAt) : 'never'} Press r to refresh.`,
|
|
288
386
|
C.dim,
|
|
289
387
|
]]));
|
|
290
388
|
if (preflightStatus !== 'n/a') {
|
|
291
389
|
detailLines.push(buildPanelLine(width, [[
|
|
292
390
|
truncateDisplay(`Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`, width),
|
|
293
|
-
preflightStatus === 'block' ? C.
|
|
391
|
+
preflightStatus === 'block' ? C.bad : preflightStatus === 'warn' ? C.warn : C.dim,
|
|
294
392
|
]]));
|
|
295
393
|
}
|
|
296
394
|
if (quarantinedMcp.length > 0) {
|
|
297
395
|
const server = quarantinedMcp[0]!;
|
|
298
396
|
detailLines.push(buildPanelLine(width, [[
|
|
299
397
|
truncateDisplay(`MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`, width),
|
|
300
|
-
C.
|
|
398
|
+
C.bad,
|
|
301
399
|
]]));
|
|
302
400
|
}
|
|
303
401
|
if (quarantinedPlugins.length > 0) {
|
|
304
402
|
const plugin = quarantinedPlugins[0]!;
|
|
305
403
|
detailLines.push(buildPanelLine(width, [[
|
|
306
404
|
truncateDisplay(`Plugin quarantine: ${plugin.name} (${plugin.trustTier})`, width),
|
|
307
|
-
C.
|
|
405
|
+
C.bad,
|
|
308
406
|
]]));
|
|
309
407
|
} else if (untrustedPlugins.length > 0) {
|
|
310
408
|
const plugin = untrustedPlugins[0]!;
|
|
@@ -331,6 +429,9 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
331
429
|
{ keys: '↑/↓', label: 'select token' },
|
|
332
430
|
{ keys: '/', label: 'filter' },
|
|
333
431
|
{ keys: 'r', label: 'refresh audit' },
|
|
432
|
+
{ keys: 'f', label: 'preflight' },
|
|
433
|
+
...(latestIncident ? [{ keys: 'i', label: 'jump to incident' }] : []),
|
|
434
|
+
...(attackPathReview.findings.length > 0 ? [{ keys: '[ / ]', label: 'scroll attack paths' }] : []),
|
|
334
435
|
];
|
|
335
436
|
|
|
336
437
|
return this.renderList(width, height, {
|
|
@@ -339,7 +440,6 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
|
|
|
339
440
|
footer: [
|
|
340
441
|
...detailLines,
|
|
341
442
|
...attackPathLines,
|
|
342
|
-
footerLine,
|
|
343
443
|
],
|
|
344
444
|
hints,
|
|
345
445
|
});
|
|
@@ -2,11 +2,13 @@ import type { Line } from '../types/grid.ts';
|
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
3
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
4
4
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
5
|
+
import type { PanelIntegrationContext } from './types.ts';
|
|
5
6
|
import {
|
|
6
7
|
type ServiceConfig,
|
|
7
8
|
type ServiceInspection,
|
|
8
9
|
type ServiceConnectionTestResult,
|
|
9
10
|
} from '@pellux/goodvibes-sdk/platform/config';
|
|
11
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
10
12
|
import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
|
|
11
13
|
import {
|
|
12
14
|
buildDetailBlock,
|
|
@@ -18,28 +20,21 @@ import {
|
|
|
18
20
|
DEFAULT_PANEL_PALETTE,
|
|
19
21
|
} from './polish.ts';
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
headerBg: '#1e293b',
|
|
25
|
-
label: '#64748b',
|
|
26
|
-
value: '#e2e8f0',
|
|
27
|
-
dim: '#475569',
|
|
28
|
-
ok: '#22c55e',
|
|
29
|
-
warn: '#eab308',
|
|
30
|
-
error: '#ef4444',
|
|
31
|
-
info: '#38bdf8',
|
|
32
|
-
selectBg: '#0f172a',
|
|
33
|
-
empty: '#334155',
|
|
34
|
-
} as const;
|
|
23
|
+
// Base chrome only — title band, state colors, and text tokens all come
|
|
24
|
+
// straight from DEFAULT_PANEL_PALETTE (WO-002).
|
|
25
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
35
26
|
|
|
36
27
|
interface ServicePanelEntry {
|
|
37
28
|
readonly name: string;
|
|
38
|
-
|
|
29
|
+
// null when registry.inspect(name) resolved null (e.g. a config entry that
|
|
30
|
+
// disappeared or failed to resolve between getAll() and inspect()) — the
|
|
31
|
+
// row still renders, flagged, instead of crashing the whole refresh.
|
|
32
|
+
readonly inspection: ServiceInspection | null;
|
|
39
33
|
readonly lastTest?: ServiceConnectionTestResult;
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
function statusLabel(entry: ServicePanelEntry): string {
|
|
37
|
+
if (!entry.inspection) return 'INSPECT FAILED';
|
|
43
38
|
if (!entry.inspection.hasPrimaryCredential) return 'UNCONFIGURED';
|
|
44
39
|
if (entry.lastTest?.ok) return 'HEALTHY';
|
|
45
40
|
if (entry.lastTest && !entry.lastTest.ok) return 'ERROR';
|
|
@@ -48,8 +43,8 @@ function statusLabel(entry: ServicePanelEntry): string {
|
|
|
48
43
|
|
|
49
44
|
function statusColor(entry: ServicePanelEntry): string {
|
|
50
45
|
const label = statusLabel(entry);
|
|
51
|
-
if (label === 'HEALTHY') return C.
|
|
52
|
-
if (label === 'ERROR') return C.
|
|
46
|
+
if (label === 'HEALTHY') return C.good;
|
|
47
|
+
if (label === 'ERROR' || label === 'INSPECT FAILED') return C.bad;
|
|
53
48
|
if (label === 'CONFIGURED') return C.warn;
|
|
54
49
|
return C.dim;
|
|
55
50
|
}
|
|
@@ -76,12 +71,16 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
76
71
|
private readonly subscriptionManager: SubscriptionAccessQuery;
|
|
77
72
|
private entries: ServicePanelEntry[] = [];
|
|
78
73
|
private loading = false;
|
|
74
|
+
// s = open the Subscription panel via the handleInput -> handlePanelIntegrationAction
|
|
75
|
+
// bridge (handleInput has no ctx.panelManager — same pattern used elsewhere in
|
|
76
|
+
// this wave, e.g. session-browser-panel.ts's pendingOpenPanels).
|
|
77
|
+
private pendingOpenSubscription = false;
|
|
79
78
|
|
|
80
79
|
public constructor(
|
|
81
80
|
registry: ServiceInspectionQuery,
|
|
82
81
|
subscriptionManager: SubscriptionAccessQuery,
|
|
83
82
|
) {
|
|
84
|
-
super('services', 'Services', 'V', '
|
|
83
|
+
super('services', 'Services', 'V', 'providers');
|
|
85
84
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
86
85
|
this.filterEnabled = true;
|
|
87
86
|
this.filterLabel = 'Filter services';
|
|
@@ -92,7 +91,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
92
91
|
|
|
93
92
|
protected override filterMatches(entry: ServicePanelEntry, q: string): boolean {
|
|
94
93
|
return entry.name.toLowerCase().includes(q)
|
|
95
|
-
|| (entry.inspection
|
|
94
|
+
|| (entry.inspection?.config.baseUrl ?? '').toLowerCase().includes(q);
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
public override onActivate(): void {
|
|
@@ -107,7 +106,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
107
106
|
protected override getEmptyStateActions() {
|
|
108
107
|
return [
|
|
109
108
|
{ command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
|
|
110
|
-
{ command: '
|
|
109
|
+
{ command: 's', summary: 'open the Subscription panel — review provider login state and override posture' },
|
|
111
110
|
];
|
|
112
111
|
}
|
|
113
112
|
|
|
@@ -117,12 +116,14 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
117
116
|
|
|
118
117
|
protected renderItem(entry: ServicePanelEntry, index: number, selected: boolean, width: number): Line {
|
|
119
118
|
const bg = selected ? C.selectBg : undefined;
|
|
119
|
+
const authText = entry.inspection ? authSummary(entry.inspection.config, this.subscriptionManager) : 'n/a';
|
|
120
|
+
const baseUrlText = entry.inspection ? (entry.inspection.config.baseUrl ?? '(no baseUrl)') : '(inspection failed)';
|
|
120
121
|
return buildPanelLine(width, [
|
|
121
122
|
[' ', C.label, bg],
|
|
122
123
|
[entry.name.padEnd(16), C.value, bg],
|
|
123
124
|
[` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
|
|
124
|
-
[` ${
|
|
125
|
-
[` ${truncateDisplay(
|
|
125
|
+
[` ${authText.padEnd(18)}`, C.info, bg],
|
|
126
|
+
[` ${truncateDisplay(baseUrlText, Math.max(0, width - 48))}`, C.dim, bg],
|
|
126
127
|
]);
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -136,43 +137,85 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
136
137
|
void this.testSelected();
|
|
137
138
|
return true;
|
|
138
139
|
}
|
|
140
|
+
if (key === 'T') {
|
|
141
|
+
void this.testAll();
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (key === 's') {
|
|
145
|
+
this.pendingOpenSubscription = true;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
139
148
|
}
|
|
140
149
|
return super.handleInput(key);
|
|
141
150
|
}
|
|
142
151
|
|
|
152
|
+
/** Drains the 's' key's pending open request via the ctx.panelManager bridge. */
|
|
153
|
+
handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
|
|
154
|
+
if (!this.pendingOpenSubscription) return false;
|
|
155
|
+
this.pendingOpenSubscription = false;
|
|
156
|
+
ctx.panelManager.open('subscription');
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
private async refresh(): Promise<void> {
|
|
144
161
|
this.loading = true;
|
|
145
162
|
this.markDirty();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
name
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
163
|
+
try {
|
|
164
|
+
const configs = this.registry.getAll();
|
|
165
|
+
const names = Object.keys(configs).sort((a, b) => a.localeCompare(b));
|
|
166
|
+
const inspections = await Promise.all(
|
|
167
|
+
names.map(async (name) => ({
|
|
168
|
+
name,
|
|
169
|
+
inspection: await this.registry.inspect(name),
|
|
170
|
+
})),
|
|
171
|
+
);
|
|
172
|
+
const previousTests = new Map(this.entries.map((entry) => [entry.name, entry.lastTest] as const));
|
|
173
|
+
this.entries = inspections.map((entry) => ({
|
|
174
|
+
...entry,
|
|
175
|
+
...(previousTests.get(entry.name) ? { lastTest: previousTests.get(entry.name) } : {}),
|
|
176
|
+
}));
|
|
177
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
this.setError(`Refresh failed: ${summarizeError(e)}`);
|
|
180
|
+
} finally {
|
|
181
|
+
// Always clear the loading flag — a thrown getAll()/inspect() must
|
|
182
|
+
// never leave the panel stuck on the "Loading configured services..." screen.
|
|
183
|
+
this.loading = false;
|
|
184
|
+
this.markDirty();
|
|
185
|
+
}
|
|
162
186
|
}
|
|
163
187
|
|
|
164
188
|
private async testSelected(): Promise<void> {
|
|
165
|
-
|
|
189
|
+
// selectedIndex tracks the filtered view, so the acted-on entry must come
|
|
190
|
+
// from getVisibleItems() — this.entries would desync under a '/' filter.
|
|
191
|
+
const selected = this.getSelectedItem();
|
|
166
192
|
if (!selected) return;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
193
|
+
try {
|
|
194
|
+
const result = await this.registry.testConnection(selected.name);
|
|
195
|
+
this.entries = this.entries.map((entry) => (
|
|
196
|
+
entry.name === selected.name
|
|
197
|
+
? { ...entry, lastTest: result }
|
|
198
|
+
: entry
|
|
199
|
+
));
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.setError(`Test failed: ${summarizeError(e)}`);
|
|
202
|
+
}
|
|
173
203
|
this.markDirty();
|
|
174
204
|
}
|
|
175
205
|
|
|
206
|
+
/** T = test every configured service's connection, one call per row (services-runtime.ts's single-service test pattern, looped). */
|
|
207
|
+
private async testAll(): Promise<void> {
|
|
208
|
+
for (const entry of this.entries) {
|
|
209
|
+
try {
|
|
210
|
+
const result = await this.registry.testConnection(entry.name);
|
|
211
|
+
this.entries = this.entries.map((e) => (e.name === entry.name ? { ...e, lastTest: result } : e));
|
|
212
|
+
} catch (e) {
|
|
213
|
+
this.setError(`Test failed for ${entry.name}: ${summarizeError(e)}`);
|
|
214
|
+
}
|
|
215
|
+
this.markDirty();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
176
219
|
public render(width: number, height: number): Line[] {
|
|
177
220
|
this.clampSelection();
|
|
178
221
|
const intro = 'Credential posture, subscription overrides, and live connection checks for configured services.';
|
|
@@ -200,13 +243,14 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
200
243
|
const headerLines: Line[] = [
|
|
201
244
|
buildKeyValueLine(width, [
|
|
202
245
|
{ label: 'services', value: String(this.entries.length), valueColor: this.entries.length > 0 ? C.info : C.dim },
|
|
203
|
-
{ label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.
|
|
204
|
-
{ label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.
|
|
246
|
+
{ label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.good : C.dim },
|
|
247
|
+
{ label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.bad : C.dim },
|
|
205
248
|
{ label: 'unconfigured', value: String(counts.unconfigured), valueColor: counts.unconfigured > 0 ? C.warn : C.dim },
|
|
206
249
|
], C),
|
|
207
250
|
];
|
|
208
251
|
|
|
209
|
-
|
|
252
|
+
// Detail must describe the row the (possibly filtered) list highlights.
|
|
253
|
+
const selected = this.getSelectedItem();
|
|
210
254
|
const detailRows: Line[] = [];
|
|
211
255
|
if (selected) {
|
|
212
256
|
const inspect = selected.inspection;
|
|
@@ -216,22 +260,28 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
216
260
|
[' State: ', C.label],
|
|
217
261
|
[statusLabel(selected), statusColor(selected)],
|
|
218
262
|
[' Auth: ', C.label],
|
|
219
|
-
[authSummary(inspect.config, this.subscriptionManager), C.info],
|
|
220
|
-
]));
|
|
221
|
-
detailRows.push(buildPanelLine(width, [
|
|
222
|
-
[' Base URL: ', C.label],
|
|
223
|
-
[truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
|
|
224
|
-
]));
|
|
225
|
-
detailRows.push(buildPanelLine(width, [
|
|
226
|
-
[' Primary credential: ', C.label],
|
|
227
|
-
...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
|
|
228
|
-
[' Webhook URL: ', C.label],
|
|
229
|
-
...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
|
|
230
|
-
[' Signing secret: ', C.label],
|
|
231
|
-
...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
|
|
232
|
-
[' App token: ', C.label],
|
|
233
|
-
...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
|
|
263
|
+
[inspect ? authSummary(inspect.config, this.subscriptionManager) : 'n/a', C.info],
|
|
234
264
|
]));
|
|
265
|
+
if (!inspect) {
|
|
266
|
+
detailRows.push(buildPanelLine(width, [
|
|
267
|
+
[' Inspection failed — the registry could not resolve this service\'s config. Press r to retry.', C.bad],
|
|
268
|
+
]));
|
|
269
|
+
} else {
|
|
270
|
+
detailRows.push(buildPanelLine(width, [
|
|
271
|
+
[' Base URL: ', C.label],
|
|
272
|
+
[truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
|
|
273
|
+
]));
|
|
274
|
+
detailRows.push(buildPanelLine(width, [
|
|
275
|
+
[' Primary credential: ', C.label],
|
|
276
|
+
...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
|
|
277
|
+
[' Webhook URL: ', C.label],
|
|
278
|
+
...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
|
|
279
|
+
[' Signing secret: ', C.label],
|
|
280
|
+
...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
|
|
281
|
+
[' App token: ', C.label],
|
|
282
|
+
...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
|
|
283
|
+
]));
|
|
284
|
+
}
|
|
235
285
|
if (selected.lastTest) {
|
|
236
286
|
detailRows.push(buildPanelLine(width, [
|
|
237
287
|
[' Last test: ', C.label],
|
|
@@ -244,7 +294,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
244
294
|
if (selected.lastTest.error) {
|
|
245
295
|
detailRows.push(buildPanelLine(width, [
|
|
246
296
|
[' Error: ', C.label],
|
|
247
|
-
[truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.
|
|
297
|
+
[truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.bad],
|
|
248
298
|
]));
|
|
249
299
|
}
|
|
250
300
|
} else {
|
|
@@ -259,8 +309,10 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
|
|
|
259
309
|
: [
|
|
260
310
|
{ keys: 'Up/Down', label: 'move' },
|
|
261
311
|
...(selected ? [{ keys: 't', label: 'test selected' }] : []),
|
|
312
|
+
...(this.entries.length > 0 ? [{ keys: 'T', label: 'test all' }] : []),
|
|
262
313
|
{ keys: 'r', label: 'refresh' },
|
|
263
314
|
{ keys: '/', label: 'filter' },
|
|
315
|
+
{ keys: 's', label: 'subscription panel' },
|
|
264
316
|
];
|
|
265
317
|
|
|
266
318
|
const footer: Line[] = selected
|