@pellux/goodvibes-tui 0.18.19 → 0.18.23
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 +170 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/conversation-rendering.ts +20 -6
- package/src/input/commands/session.ts +0 -1
- package/src/input/feed-context-factory.ts +236 -0
- package/src/input/handler-feed-routes.ts +10 -0
- package/src/input/handler-feed.ts +44 -6
- package/src/input/handler-shortcuts.ts +138 -125
- package/src/input/handler.ts +121 -119
- package/src/input/keybindings.ts +30 -0
- package/src/panels/approval-panel.ts +54 -74
- package/src/panels/automation-control-panel.ts +119 -161
- package/src/panels/base-panel.ts +71 -0
- package/src/panels/communication-panel.ts +68 -107
- package/src/panels/confirm-state.ts +61 -0
- package/src/panels/control-plane-panel.ts +116 -172
- package/src/panels/git-panel.ts +9 -0
- package/src/panels/hooks-panel.ts +101 -138
- package/src/panels/incident-review-panel.ts +55 -107
- package/src/panels/knowledge-panel.ts +63 -14
- package/src/panels/local-auth-panel.ts +76 -93
- package/src/panels/marketplace-panel.ts +19 -12
- package/src/panels/mcp-panel.ts +108 -155
- package/src/panels/ops-control-panel.ts +50 -85
- package/src/panels/panel-manager.ts +22 -2
- package/src/panels/plugins-panel.ts +36 -60
- package/src/panels/routes-panel.ts +89 -141
- package/src/panels/scrollable-list-panel.ts +71 -16
- package/src/panels/security-panel.ts +101 -137
- package/src/panels/services-panel.ts +58 -102
- package/src/panels/settings-sync-panel.ts +76 -122
- package/src/panels/skills-panel.ts +44 -0
- package/src/panels/subscription-panel.ts +69 -80
- package/src/panels/tasks-panel.ts +129 -179
- package/src/panels/watchers-panel.ts +88 -137
- package/src/renderer/buffer.ts +11 -0
- package/src/renderer/diff.ts +8 -0
- package/src/renderer/help-overlay.ts +37 -28
- package/src/renderer/markdown.ts +3 -145
- package/src/renderer/status-token.ts +71 -0
- package/src/version.ts +1 -1
package/src/panels/mcp-panel.ts
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
|
-
import {
|
|
3
|
-
import { BasePanel } from './base-panel.ts';
|
|
2
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
3
|
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
|
|
5
4
|
import type { McpDecisionRecord } from '@pellux/goodvibes-sdk/platform/runtime/mcp/types';
|
|
5
|
+
|
|
6
|
+
type McpServerSecurityEntry = ReturnType<McpRegistry['listServerSecurity']>[number];
|
|
6
7
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
7
8
|
import {
|
|
8
|
-
buildEmptyState,
|
|
9
9
|
buildGuidanceLine,
|
|
10
10
|
buildKeyValueLine,
|
|
11
11
|
buildPanelLine,
|
|
12
|
-
buildPanelWorkspace,
|
|
13
12
|
DEFAULT_PANEL_PALETTE,
|
|
14
|
-
resolvePrimaryScrollableSection,
|
|
15
|
-
type PanelWorkspaceSection,
|
|
16
13
|
} from './polish.ts';
|
|
17
14
|
|
|
18
15
|
const C = {
|
|
@@ -60,93 +57,101 @@ function decisionColor(decision: McpDecisionRecord): string {
|
|
|
60
57
|
return decision.incoherent ? C.warn : C.ok;
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
export class McpPanel extends
|
|
60
|
+
export class McpPanel extends ScrollableListPanel<McpServerSecurityEntry> {
|
|
64
61
|
private readonly registry: McpRegistry;
|
|
65
|
-
private selectedIndex = 0;
|
|
66
|
-
private scrollOffset = 0;
|
|
67
62
|
|
|
68
63
|
public constructor(registry: McpRegistry) {
|
|
69
64
|
super('mcp', 'MCP', 'Z', 'monitoring');
|
|
70
65
|
this.registry = registry;
|
|
71
66
|
}
|
|
72
67
|
|
|
68
|
+
protected override getPalette() { return C; }
|
|
69
|
+
protected override getEmptyStateMessage() { return ' No MCP servers configured or connected.'; }
|
|
70
|
+
protected override getEmptyStateActions() {
|
|
71
|
+
return [
|
|
72
|
+
{ command: '/mcp', summary: 'list server state and security posture' },
|
|
73
|
+
{ command: '/settings', summary: 'open the MCP settings category for trust and scope controls' },
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
protected getItems(): readonly McpServerSecurityEntry[] {
|
|
78
|
+
return this.registry.listServerSecurity();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected renderItem(entry: McpServerSecurityEntry, index: number, selected: boolean, width: number): Line {
|
|
82
|
+
const bg = selected ? C.selectBg : undefined;
|
|
83
|
+
return buildPanelLine(width, [
|
|
84
|
+
[' ', C.label, bg],
|
|
85
|
+
[entry.name.padEnd(20), C.value, bg],
|
|
86
|
+
[` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, entry.connected ? C.ok : C.error, bg],
|
|
87
|
+
[` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
|
|
88
|
+
[` ${entry.role.padEnd(10)}`, C.info, bg],
|
|
89
|
+
[` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
public handleInput(key: string): boolean {
|
|
74
|
-
const entries = this.registry.listServerSecurity();
|
|
75
94
|
if (key === 'r') {
|
|
76
95
|
this.markDirty();
|
|
77
96
|
return true;
|
|
78
97
|
}
|
|
79
|
-
|
|
80
|
-
if (key === 'up' || key === 'k') {
|
|
81
|
-
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
82
|
-
this.markDirty();
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
if (key === 'down' || key === 'j') {
|
|
86
|
-
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
87
|
-
this.markDirty();
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
98
|
+
return super.handleInput(key);
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
public render(width: number, height: number): Line[] {
|
|
94
|
-
this.
|
|
95
|
-
const intro = 'Trust, quarantine, scope, and recent security decisions for configured MCP servers.';
|
|
102
|
+
this.clampSelection();
|
|
96
103
|
const entries = this.registry.listServerSecurity();
|
|
104
|
+
const intro = 'Trust, quarantine, scope, and recent security decisions for configured MCP servers.';
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
title: 'MCP Control Room',
|
|
101
|
-
intro,
|
|
102
|
-
sections: [{
|
|
103
|
-
lines: buildEmptyState(
|
|
104
|
-
width,
|
|
105
|
-
' No MCP servers configured or connected.',
|
|
106
|
-
'Add MCP servers, inspect trust posture, and review risk-scoped policies here once the registry is populated.',
|
|
107
|
-
[
|
|
108
|
-
{ command: '/mcp', summary: 'list server state and security posture' },
|
|
109
|
-
{ command: '/settings', summary: 'open the MCP settings category for trust and scope controls' },
|
|
110
|
-
],
|
|
111
|
-
C,
|
|
112
|
-
),
|
|
113
|
-
}],
|
|
114
|
-
palette: C,
|
|
115
|
-
});
|
|
116
|
-
while (workspace.length < height) workspace.push(createEmptyLine(width));
|
|
117
|
-
return workspace;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
this.selectedIndex = Math.min(this.selectedIndex, entries.length - 1);
|
|
121
|
-
const selected = entries[this.selectedIndex]!;
|
|
122
|
-
const sandboxBinding = this.registry.listServerSandboxBindings().find((entry) => entry.name === selected.name);
|
|
123
|
-
const connected = entries.filter((entry) => entry.connected).length;
|
|
124
|
-
const quarantined = entries.filter((entry) => entry.schemaFreshness === 'quarantined').length;
|
|
106
|
+
const connected = entries.filter((e) => e.connected).length;
|
|
107
|
+
const quarantined = entries.filter((e) => e.schemaFreshness === 'quarantined').length;
|
|
125
108
|
const disconnected = entries.length - connected;
|
|
126
|
-
const staleSchemas = entries.filter((
|
|
127
|
-
|
|
128
|
-
|
|
109
|
+
const staleSchemas = entries.filter((e) => e.schemaFreshness !== 'fresh').length;
|
|
110
|
+
|
|
111
|
+
const headerLines: Line[] = [
|
|
112
|
+
buildPanelLine(width, [[' MCP posture', C.label]]),
|
|
113
|
+
buildKeyValueLine(width, [
|
|
114
|
+
{ label: 'servers', value: String(entries.length), valueColor: C.value },
|
|
115
|
+
{ label: 'connected', value: String(connected), valueColor: connected > 0 ? C.ok : C.dim },
|
|
116
|
+
{ label: 'disconnected', value: String(disconnected), valueColor: disconnected > 0 ? C.warn : C.dim },
|
|
117
|
+
{ label: 'stale schema', value: String(staleSchemas), valueColor: staleSchemas > 0 ? C.warn : C.dim },
|
|
118
|
+
{ label: 'quarantined', value: String(quarantined), valueColor: quarantined > 0 ? C.error : C.dim },
|
|
119
|
+
], C),
|
|
120
|
+
buildGuidanceLine(width, '/mcp review', 'inspect trust, freshness, and quarantine posture for configured servers', C),
|
|
121
|
+
buildGuidanceLine(width, '/mcp repair', 'review reconnect, auth, import, and startup remediation guidance', C),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const selected = entries[this.selectedIndex];
|
|
125
|
+
const detailLines: Line[] = [];
|
|
126
|
+
const repairLines: Line[] = [];
|
|
127
|
+
|
|
128
|
+
if (selected) {
|
|
129
|
+
const sandboxBinding = this.registry.listServerSandboxBindings().find((e) => e.name === selected.name);
|
|
130
|
+
const decisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
|
|
131
|
+
const selectedDecision = decisions.find((d) => d.serverName === selected.name);
|
|
132
|
+
|
|
133
|
+
detailLines.push(buildPanelLine(width, [
|
|
129
134
|
[' Server: ', C.label],
|
|
130
135
|
[selected.name, C.value],
|
|
131
136
|
[' Trust: ', C.label],
|
|
132
137
|
[selected.trustMode, modeColor(selected.trustMode)],
|
|
133
138
|
[' Role: ', C.label],
|
|
134
139
|
[selected.role, C.info],
|
|
135
|
-
])
|
|
136
|
-
buildPanelLine(width, [
|
|
140
|
+
]));
|
|
141
|
+
detailLines.push(buildPanelLine(width, [
|
|
137
142
|
[' Schema: ', C.label],
|
|
138
143
|
[selected.schemaFreshness, freshnessColor(selected.schemaFreshness)],
|
|
139
144
|
[' Approved by: ', C.label],
|
|
140
145
|
[truncateDisplay(selected.quarantineApprovedBy ?? 'n/a', Math.max(0, width - 31)), selected.quarantineApprovedBy ? C.info : C.dim],
|
|
141
|
-
])
|
|
142
|
-
buildPanelLine(width, [
|
|
146
|
+
]));
|
|
147
|
+
detailLines.push(buildPanelLine(width, [
|
|
143
148
|
[' Scope: ', C.label],
|
|
144
149
|
[truncateDisplay(
|
|
145
150
|
`paths ${selected.allowedPaths.length > 0 ? selected.allowedPaths.join(', ') : 'unbounded'} hosts ${selected.allowedHosts.length > 0 ? selected.allowedHosts.join(', ') : 'unbounded'}`,
|
|
146
151
|
Math.max(0, width - 10),
|
|
147
152
|
), (selected.allowedPaths.length > 0 || selected.allowedHosts.length > 0) ? C.value : C.dim],
|
|
148
|
-
])
|
|
149
|
-
buildPanelLine(width, [
|
|
153
|
+
]));
|
|
154
|
+
detailLines.push(buildPanelLine(width, [
|
|
150
155
|
[' Sandbox: ', C.label],
|
|
151
156
|
[truncateDisplay(
|
|
152
157
|
sandboxBinding?.sessionId
|
|
@@ -154,107 +159,55 @@ export class McpPanel extends BasePanel {
|
|
|
154
159
|
: 'not isolated',
|
|
155
160
|
Math.max(0, width - 13),
|
|
156
161
|
), sandboxBinding?.sessionId ? C.info : C.dim],
|
|
157
|
-
]),
|
|
158
|
-
];
|
|
159
|
-
if (selected.schemaFreshness === 'quarantined') {
|
|
160
|
-
detailLines.push(buildPanelLine(width, [
|
|
161
|
-
[' Quarantine: ', C.label],
|
|
162
|
-
[truncateDisplay(`${selected.quarantineReason ?? 'unknown'}${selected.quarantineDetail ? ` - ${selected.quarantineDetail}` : ''}`, Math.max(0, width - 15)), C.error],
|
|
163
162
|
]));
|
|
164
|
-
|
|
163
|
+
if (selected.schemaFreshness === 'quarantined') {
|
|
164
|
+
detailLines.push(buildPanelLine(width, [
|
|
165
|
+
[' Quarantine: ', C.label],
|
|
166
|
+
[truncateDisplay(`${selected.quarantineReason ?? 'unknown'}${selected.quarantineDetail ? ` - ${selected.quarantineDetail}` : ''}`, Math.max(0, width - 15)), C.error],
|
|
167
|
+
]));
|
|
168
|
+
}
|
|
169
|
+
if (selectedDecision) {
|
|
170
|
+
const summary = `${selectedDecision.serverName}:${selectedDecision.toolName} ${selectedDecision.verdict.toUpperCase()} ${selectedDecision.capability}${selectedDecision.incoherent ? ' incoherent' : ''}`;
|
|
171
|
+
detailLines.push(buildPanelLine(width, [
|
|
172
|
+
[' Recent: ', C.label],
|
|
173
|
+
[truncateDisplay(summary, Math.max(0, width - 10)), decisionColor(selectedDecision)],
|
|
174
|
+
]));
|
|
175
|
+
}
|
|
165
176
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const summary = `${decision.serverName}:${decision.toolName} ${decision.verdict.toUpperCase()} ${decision.capability}${decision.incoherent ? ' incoherent' : ''}`;
|
|
179
|
-
return buildPanelLine(width, [
|
|
180
|
-
[' ', C.label],
|
|
181
|
-
[truncateDisplay(summary, Math.max(0, width - 2)), decisionColor(decision)],
|
|
182
|
-
]);
|
|
183
|
-
});
|
|
177
|
+
if (!selected.connected) {
|
|
178
|
+
repairLines.push(buildPanelLine(width, [[' /mcp repair', C.warn], [' review reconnect and startup posture for this server', C.dim]]));
|
|
179
|
+
}
|
|
180
|
+
if (selected.schemaFreshness !== 'fresh') {
|
|
181
|
+
repairLines.push(buildPanelLine(width, [[' /mcp review', C.warn], [' inspect schema freshness, quarantine, and trust posture', C.dim]]));
|
|
182
|
+
}
|
|
183
|
+
if (sandboxBinding?.sessionId) {
|
|
184
|
+
repairLines.push(buildPanelLine(width, [[' /sandbox review', C.info], [' verify the bound MCP isolation session and startup status', C.dim]]));
|
|
185
|
+
}
|
|
186
|
+
if (repairLines.length === 0) {
|
|
187
|
+
repairLines.push(buildPanelLine(width, [[' No immediate MCP repair actions suggested for the selected server.', C.dim]]));
|
|
188
|
+
}
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
const allDecisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
|
|
191
|
+
const decisionLines: Line[] = allDecisions.length === 0
|
|
192
|
+
? [buildPanelLine(width, [[' No MCP decisions recorded yet.', C.dim]])]
|
|
193
|
+
: allDecisions.map((decision) => {
|
|
194
|
+
const summary = `${decision.serverName}:${decision.toolName} ${decision.verdict.toUpperCase()} ${decision.capability}${decision.incoherent ? ' incoherent' : ''}`;
|
|
195
|
+
return buildPanelLine(width, [
|
|
196
|
+
[' ', C.label],
|
|
197
|
+
[truncateDisplay(summary, Math.max(0, width - 2)), decisionColor(decision)],
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
detailLines.push(...repairLines);
|
|
201
|
+
detailLines.push(...decisionLines);
|
|
194
202
|
}
|
|
195
|
-
if (repairLines.length === 0) {
|
|
196
|
-
repairLines.push(buildPanelLine(width, [[' No immediate MCP repair actions suggested for the selected server.', C.dim]]));
|
|
197
|
-
}
|
|
198
|
-
const postureSection: PanelWorkspaceSection = {
|
|
199
|
-
title: 'MCP posture',
|
|
200
|
-
lines: [
|
|
201
|
-
buildKeyValueLine(width, [
|
|
202
|
-
{ label: 'servers', value: String(entries.length), valueColor: C.value },
|
|
203
|
-
{ label: 'connected', value: String(connected), valueColor: connected > 0 ? C.ok : C.dim },
|
|
204
|
-
{ label: 'disconnected', value: String(disconnected), valueColor: disconnected > 0 ? C.warn : C.dim },
|
|
205
|
-
{ label: 'stale schema', value: String(staleSchemas), valueColor: staleSchemas > 0 ? C.warn : C.dim },
|
|
206
|
-
{ label: 'quarantined', value: String(quarantined), valueColor: quarantined > 0 ? C.error : C.dim },
|
|
207
|
-
], C),
|
|
208
|
-
buildGuidanceLine(width, '/mcp review', 'inspect trust, freshness, and quarantine posture for configured servers', C),
|
|
209
|
-
buildGuidanceLine(width, '/mcp repair', 'review reconnect, auth, import, and startup remediation guidance', C),
|
|
210
|
-
],
|
|
211
|
-
};
|
|
212
|
-
const selectedSection: PanelWorkspaceSection = { title: 'Selected Server', lines: detailLines };
|
|
213
|
-
const repairSection: PanelWorkspaceSection = { title: 'Repair', lines: repairLines };
|
|
214
|
-
const decisionsSection: PanelWorkspaceSection = { title: 'Recent Decisions', lines: decisionLines };
|
|
215
|
-
const resolvedServersSection = resolvePrimaryScrollableSection(width, height, {
|
|
216
|
-
intro,
|
|
217
|
-
footerLines: [buildPanelLine(width, [[' Up/Down move r refresh', C.dim]])],
|
|
218
|
-
palette: C,
|
|
219
|
-
beforeSections: [postureSection],
|
|
220
|
-
section: {
|
|
221
|
-
title: 'Servers',
|
|
222
|
-
scrollableLines: entries.map((entry, absolute) => {
|
|
223
|
-
const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
|
|
224
|
-
return buildPanelLine(width, [
|
|
225
|
-
[' ', C.label, bg],
|
|
226
|
-
[entry.name.padEnd(20), C.value, bg],
|
|
227
|
-
[` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, entry.connected ? C.ok : C.error, bg],
|
|
228
|
-
[` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
|
|
229
|
-
[` ${entry.role.padEnd(10)}`, C.info, bg],
|
|
230
|
-
[` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],
|
|
231
|
-
]);
|
|
232
|
-
}),
|
|
233
|
-
selectedIndex: this.selectedIndex,
|
|
234
|
-
scrollOffset: this.scrollOffset,
|
|
235
|
-
guardRows: 1,
|
|
236
|
-
minRows: 4,
|
|
237
|
-
appendWindowSummary: { dimColor: C.dim },
|
|
238
|
-
},
|
|
239
|
-
afterSections: [selectedSection, repairSection, decisionsSection],
|
|
240
|
-
});
|
|
241
|
-
this.scrollOffset = resolvedServersSection.scrollOffset;
|
|
242
203
|
|
|
243
|
-
|
|
244
|
-
postureSection,
|
|
245
|
-
resolvedServersSection.section,
|
|
246
|
-
selectedSection,
|
|
247
|
-
repairSection,
|
|
248
|
-
decisionsSection,
|
|
249
|
-
];
|
|
250
|
-
const lines = buildPanelWorkspace(width, height, {
|
|
204
|
+
return this.renderList(width, height, {
|
|
251
205
|
title: 'MCP Control Room',
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
206
|
+
header: headerLines,
|
|
207
|
+
footer: [
|
|
208
|
+
...detailLines,
|
|
209
|
+
buildPanelLine(width, [[' Up/Down move r refresh', C.dim]]),
|
|
210
|
+
],
|
|
256
211
|
});
|
|
257
|
-
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
258
|
-
return lines.slice(0, height);
|
|
259
212
|
}
|
|
260
213
|
}
|
|
@@ -12,15 +12,11 @@ import type { OpsEvent } from '@pellux/goodvibes-sdk/platform/runtime/events/ind
|
|
|
12
12
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
13
13
|
import type { OpsAuditEntry } from '../runtime/diagnostics/panels/ops.ts';
|
|
14
14
|
import { OpsPanel } from '../runtime/diagnostics/panels/ops.ts';
|
|
15
|
-
import {
|
|
16
|
-
import { createEmptyLine } from '../types/grid.ts';
|
|
15
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
17
16
|
import {
|
|
18
|
-
buildEmptyState,
|
|
19
17
|
buildPanelLine,
|
|
20
|
-
buildPanelWorkspace,
|
|
21
|
-
resolveScrollablePanelSection,
|
|
22
18
|
DEFAULT_PANEL_PALETTE,
|
|
23
|
-
type
|
|
19
|
+
type PanelPalette,
|
|
24
20
|
} from './polish.ts';
|
|
25
21
|
|
|
26
22
|
// ── Colour palette ──────────────────────────────────────────────────────────
|
|
@@ -74,10 +70,9 @@ function targetColor(kind: OpsAuditEntry['targetKind']): string {
|
|
|
74
70
|
|
|
75
71
|
// ── OpsControlPanel ──────────────────────────────────────────────────────────
|
|
76
72
|
|
|
77
|
-
export class OpsControlPanel extends
|
|
73
|
+
export class OpsControlPanel extends ScrollableListPanel<OpsAuditEntry> {
|
|
78
74
|
private readonly _opsPanel: OpsPanel;
|
|
79
75
|
private _unsub: (() => void) | null = null;
|
|
80
|
-
private _scrollOffset = 0;
|
|
81
76
|
|
|
82
77
|
public constructor(eventFeed: UiEventFeed<OpsEvent>) {
|
|
83
78
|
super('ops-control', 'Ops Control', 'Q', 'agent');
|
|
@@ -87,13 +82,7 @@ export class OpsControlPanel extends BasePanel {
|
|
|
87
82
|
|
|
88
83
|
public override onActivate(): void {
|
|
89
84
|
super.onActivate();
|
|
90
|
-
this.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
public handleInput(key: string): boolean {
|
|
94
|
-
if (key === 'up' || key === 'k') { this._scrollOffset = Math.max(0, this._scrollOffset - 1); return true; }
|
|
95
|
-
if (key === 'down' || key === 'j') { this._scrollOffset++; return true; }
|
|
96
|
-
return false;
|
|
85
|
+
this.selectedIndex = 0;
|
|
97
86
|
}
|
|
98
87
|
|
|
99
88
|
public override onDestroy(): void {
|
|
@@ -104,81 +93,57 @@ export class OpsControlPanel extends BasePanel {
|
|
|
104
93
|
this._opsPanel.dispose();
|
|
105
94
|
}
|
|
106
95
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
protected override getPalette(): PanelPalette {
|
|
97
|
+
return C;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
protected getItems(): readonly OpsAuditEntry[] {
|
|
101
|
+
// Return reversed so newest entries appear at top
|
|
102
|
+
return [...this._opsPanel.getSnapshot()].reverse();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
protected renderItem(entry: OpsAuditEntry, _index: number, _selected: boolean, width: number): Line {
|
|
106
|
+
const seqStr = String(entry.seq).padStart(4, ' ');
|
|
107
|
+
const timeStr = fmtTime(entry.ts);
|
|
108
|
+
const action = entry.action.slice(0, 15).padEnd(15, ' ');
|
|
109
|
+
const kindTag = entry.targetKind === 'task' ? 'T:' : 'A:';
|
|
110
|
+
// Truncation is intentional: TUI column width limits target ID display to 14 chars
|
|
111
|
+
const shortId = entry.targetId.slice(-10);
|
|
112
|
+
const target = (kindTag + shortId).slice(0, 14).padEnd(14, ' ');
|
|
113
|
+
const outLabel = outcomeLabel(entry.outcome);
|
|
114
|
+
const noteRaw = (entry.note ?? entry.errorMessage ?? '').slice(0, Math.max(0, width - 63));
|
|
115
|
+
|
|
116
|
+
const segs: Array<[string, string, string?]> = [
|
|
117
|
+
[` ${seqStr} `, C.seq],
|
|
118
|
+
[`${timeStr} `, C.dim],
|
|
119
|
+
[`${action} `, C.value],
|
|
120
|
+
[`${target} `, targetColor(entry.targetKind)],
|
|
121
|
+
[outLabel, outcomeColor(entry.outcome)],
|
|
122
|
+
];
|
|
123
|
+
if (noteRaw) segs.push([` ${noteRaw}`, C.note]);
|
|
124
|
+
return buildPanelLine(width, segs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected override getEmptyStateMessage(): string {
|
|
128
|
+
return ' No operator interventions recorded.';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
|
|
132
|
+
return [{ command: '/cockpit', summary: 'open the cockpit and drive runtime interventions from the control rooms' }];
|
|
133
|
+
}
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
const
|
|
135
|
+
public render(width: number, height: number): Line[] {
|
|
136
|
+
const headerLines: Line[] = [
|
|
133
137
|
buildPanelLine(width, [[' SEQ TIME ACTION TARGET OUT NOTE', C.label]]),
|
|
134
138
|
];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const action = entry.action.slice(0, 15).padEnd(15, ' ');
|
|
139
|
-
const kindTag = entry.targetKind === 'task' ? 'T:' : 'A:';
|
|
140
|
-
// Truncation is intentional: TUI column width limits target ID display to 14 chars
|
|
141
|
-
const shortId = entry.targetId.slice(-10);
|
|
142
|
-
const target = (kindTag + shortId).slice(0, 14).padEnd(14, ' ');
|
|
143
|
-
const outLabel = outcomeLabel(entry.outcome);
|
|
144
|
-
const noteRaw = (entry.note ?? entry.errorMessage ?? '').slice(0, Math.max(0, width - 63));
|
|
145
|
-
|
|
146
|
-
const segs: Array<[string, string, string?]> = [
|
|
147
|
-
[` ${seqStr} `, C.seq],
|
|
148
|
-
[`${timeStr} `, C.dim],
|
|
149
|
-
[`${action} `, C.value],
|
|
150
|
-
[`${target} `, targetColor(entry.targetKind)],
|
|
151
|
-
[outLabel, outcomeColor(entry.outcome)],
|
|
152
|
-
];
|
|
153
|
-
if (noteRaw) segs.push([` ${noteRaw}`, C.note]);
|
|
154
|
-
entryRows.push(buildPanelLine(width, segs));
|
|
155
|
-
}
|
|
156
|
-
const logSection = resolveScrollablePanelSection(width, height, {
|
|
157
|
-
intro,
|
|
158
|
-
footerLines: [buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]])],
|
|
159
|
-
palette: C,
|
|
160
|
-
section: {
|
|
161
|
-
title: 'Audit Log',
|
|
162
|
-
scrollableLines: entryRows,
|
|
163
|
-
scrollOffset: this._scrollOffset,
|
|
164
|
-
minRows: 4,
|
|
165
|
-
appendWindowSummary: {
|
|
166
|
-
dimColor: C.label,
|
|
167
|
-
formatter: (window) => buildPanelLine(width, [[` [${window.start + 1}-${window.end}/${window.total}] Up/Down to scroll`.slice(0, width), C.label]]),
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
this._scrollOffset = logSection.scrollOffset;
|
|
139
|
+
const footerLines: Line[] = [
|
|
140
|
+
buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]]),
|
|
141
|
+
];
|
|
172
142
|
|
|
173
|
-
|
|
174
|
-
const lines = buildPanelWorkspace(width, height, {
|
|
143
|
+
return this.renderList(width, height, {
|
|
175
144
|
title: 'Operator Control Plane',
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
footerLines: [buildPanelLine(width, [[' Up/Down scroll the intervention log', C.dim]])],
|
|
179
|
-
palette: C,
|
|
145
|
+
header: headerLines,
|
|
146
|
+
footer: footerLines,
|
|
180
147
|
});
|
|
181
|
-
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
182
|
-
return lines;
|
|
183
148
|
}
|
|
184
149
|
}
|
|
@@ -39,6 +39,9 @@ export class PanelManager {
|
|
|
39
39
|
private _verticalSplitRatio: number = 0.5; // top gets 50% of panel height
|
|
40
40
|
private _bottomPaneVisible: boolean = false;
|
|
41
41
|
|
|
42
|
+
// Cache for getWorkspaceTabs() — invalidated on every panel lifecycle event
|
|
43
|
+
private _cachedWorkspaceTabs: readonly WorkspaceTab[] | null = null;
|
|
44
|
+
|
|
42
45
|
// -------------------------------------------------------------------------
|
|
43
46
|
// Registration
|
|
44
47
|
// -------------------------------------------------------------------------
|
|
@@ -79,6 +82,11 @@ export class PanelManager {
|
|
|
79
82
|
// Panel lifecycle — operates on a specific pane (defaults to focused)
|
|
80
83
|
// -------------------------------------------------------------------------
|
|
81
84
|
|
|
85
|
+
/** Invalidate the workspace tab cache. Call on every panel lifecycle mutation. */
|
|
86
|
+
private _invalidateWorkspaceTabs(): void {
|
|
87
|
+
this._cachedWorkspaceTabs = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
open(panelId: string, pane?: 'top' | 'bottom'): Panel {
|
|
83
91
|
const existingPane = this._findPaneOf(panelId);
|
|
84
92
|
if (existingPane) {
|
|
@@ -107,6 +115,7 @@ export class PanelManager {
|
|
|
107
115
|
this._focusedPane = 'top';
|
|
108
116
|
}
|
|
109
117
|
panel.onActivate();
|
|
118
|
+
this._invalidateWorkspaceTabs();
|
|
110
119
|
return panel;
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -146,6 +155,7 @@ export class PanelManager {
|
|
|
146
155
|
if (this.topPane.panels.length === 0 && this.bottomPane.panels.length === 0) {
|
|
147
156
|
this._visible = false;
|
|
148
157
|
}
|
|
158
|
+
this._invalidateWorkspaceTabs();
|
|
149
159
|
return;
|
|
150
160
|
}
|
|
151
161
|
}
|
|
@@ -187,6 +197,7 @@ export class PanelManager {
|
|
|
187
197
|
p.activeIndex = (p.activeIndex + 1) % p.panels.length;
|
|
188
198
|
const newPanel = p.panels[p.activeIndex];
|
|
189
199
|
if (newPanel) newPanel.onActivate();
|
|
200
|
+
this._invalidateWorkspaceTabs();
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
nextWorkspaceTab(): void {
|
|
@@ -216,6 +227,7 @@ export class PanelManager {
|
|
|
216
227
|
p.activeIndex = index;
|
|
217
228
|
const newPanel = p.panels[p.activeIndex];
|
|
218
229
|
if (newPanel) newPanel.onActivate();
|
|
230
|
+
this._invalidateWorkspaceTabs();
|
|
219
231
|
}
|
|
220
232
|
|
|
221
233
|
activateById(panelId: string): void {
|
|
@@ -231,6 +243,7 @@ export class PanelManager {
|
|
|
231
243
|
focusPane(pane: 'top' | 'bottom'): void {
|
|
232
244
|
if (pane === 'bottom' && !this._bottomPaneVisible) return;
|
|
233
245
|
this._focusedPane = pane;
|
|
246
|
+
this._invalidateWorkspaceTabs();
|
|
234
247
|
}
|
|
235
248
|
|
|
236
249
|
getFocusedPane(): 'top' | 'bottom' {
|
|
@@ -253,6 +266,7 @@ export class PanelManager {
|
|
|
253
266
|
// -------------------------------------------------------------------------
|
|
254
267
|
|
|
255
268
|
toggleBottomPane(): void {
|
|
269
|
+
this._invalidateWorkspaceTabs();
|
|
256
270
|
if (this._bottomPaneVisible) {
|
|
257
271
|
this._bottomPaneVisible = false;
|
|
258
272
|
if (this._focusedPane === 'bottom') this._focusedPane = 'top';
|
|
@@ -329,7 +343,8 @@ export class PanelManager {
|
|
|
329
343
|
return this._findPaneOf(panelId);
|
|
330
344
|
}
|
|
331
345
|
|
|
332
|
-
getWorkspaceTabs(): WorkspaceTab[] {
|
|
346
|
+
getWorkspaceTabs(): readonly WorkspaceTab[] {
|
|
347
|
+
if (this._cachedWorkspaceTabs !== null) return this._cachedWorkspaceTabs;
|
|
333
348
|
const focusedPanelId = this.getActivePanel()?.id;
|
|
334
349
|
const topTabs = this.topPane.panels.map((panel) => ({
|
|
335
350
|
id: panel.id,
|
|
@@ -347,7 +362,9 @@ export class PanelManager {
|
|
|
347
362
|
active: panel.id === focusedPanelId,
|
|
348
363
|
focused: panel.id === focusedPanelId,
|
|
349
364
|
}));
|
|
350
|
-
|
|
365
|
+
const tabs = [...topTabs, ...bottomTabs] as WorkspaceTab[];
|
|
366
|
+
this._cachedWorkspaceTabs = tabs;
|
|
367
|
+
return tabs;
|
|
351
368
|
}
|
|
352
369
|
|
|
353
370
|
activateWorkspaceIndex(index: number): void {
|
|
@@ -357,6 +374,7 @@ export class PanelManager {
|
|
|
357
374
|
this._focusedPane = tab.pane;
|
|
358
375
|
if (tab.pane === 'bottom') this._bottomPaneVisible = true;
|
|
359
376
|
this._activateByIdInPane(tab.id, tab.pane);
|
|
377
|
+
this._invalidateWorkspaceTabs();
|
|
360
378
|
}
|
|
361
379
|
|
|
362
380
|
// -------------------------------------------------------------------------
|
|
@@ -491,6 +509,7 @@ export class PanelManager {
|
|
|
491
509
|
this._bottomPaneVisible = true;
|
|
492
510
|
}
|
|
493
511
|
this._focusedPane = dstPaneName;
|
|
512
|
+
this._invalidateWorkspaceTabs();
|
|
494
513
|
}
|
|
495
514
|
|
|
496
515
|
private _cycleWorkspaceTab(direction: 1 | -1): void {
|
|
@@ -533,6 +552,7 @@ export class PanelManager {
|
|
|
533
552
|
p.activeIndex = index;
|
|
534
553
|
const newPanel = p.panels[p.activeIndex];
|
|
535
554
|
if (newPanel) newPanel.onActivate();
|
|
555
|
+
this._invalidateWorkspaceTabs();
|
|
536
556
|
}
|
|
537
557
|
}
|
|
538
558
|
}
|