@pellux/goodvibes-tui 0.19.98 → 0.19.99
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 +5 -0
- package/README.md +22 -3
- package/docs/foundation-artifacts/operator-contract.json +1376 -290
- package/package.json +2 -2
- package/src/input/command-registry.ts +1 -1
- package/src/input/commands/mcp-runtime.ts +237 -7
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +1 -0
- package/src/input/handler-modal-stack.ts +3 -0
- package/src/input/handler-modal-token-routes.ts +11 -0
- package/src/input/handler-ui-state.ts +10 -0
- package/src/input/handler.ts +10 -0
- package/src/input/mcp-workspace.ts +554 -0
- package/src/mcp/runtime-reload.ts +81 -0
- package/src/panels/builtin/operations.ts +1 -11
- package/src/panels/builtin/shared.ts +2 -2
- package/src/panels/index.ts +0 -1
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/mcp-workspace.ts +297 -0
- package/src/runtime/bootstrap-command-parts.ts +2 -4
- package/src/runtime/bootstrap.ts +26 -2
- package/src/shell/ui-openers.ts +5 -0
- package/src/version.ts +1 -1
- package/src/panels/mcp-panel.ts +0 -215
package/src/panels/index.ts
CHANGED
|
@@ -35,7 +35,6 @@ export { CockpitPanel } from './cockpit-panel.ts';
|
|
|
35
35
|
export { RemotePanel } from './remote-panel.ts';
|
|
36
36
|
export { ServicesPanel } from './services-panel.ts';
|
|
37
37
|
export { SubscriptionPanel } from './subscription-panel.ts';
|
|
38
|
-
export { McpPanel } from './mcp-panel.ts';
|
|
39
38
|
export { HooksPanel } from './hooks-panel.ts';
|
|
40
39
|
export { SecurityPanel } from './security-panel.ts';
|
|
41
40
|
export { MarketplacePanel } from './marketplace-panel.ts';
|
|
@@ -13,6 +13,7 @@ import { renderAgentDetailModal } from './agent-detail-modal.ts';
|
|
|
13
13
|
import { renderLiveTailModal } from './live-tail-modal.ts';
|
|
14
14
|
import { renderContextInspector } from './context-inspector.ts';
|
|
15
15
|
import { renderSettingsModal } from './settings-modal.ts';
|
|
16
|
+
import { renderMcpWorkspace } from './mcp-workspace.ts';
|
|
16
17
|
import { renderSessionPickerModal } from './session-picker-modal.ts';
|
|
17
18
|
import { renderProfilePickerModal } from './profile-picker-modal.ts';
|
|
18
19
|
import { renderBookmarkModal } from './bookmark-modal.ts';
|
|
@@ -92,6 +93,11 @@ export function applyConversationOverlays(
|
|
|
92
93
|
next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
if (input.mcpWorkspace.active) {
|
|
97
|
+
const lines = renderMcpWorkspace(input.mcpWorkspace, conversationWidth, viewportHeight);
|
|
98
|
+
next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
if (input.sessionPickerModal.active) {
|
|
96
102
|
const lines = renderSessionPickerModal(input.sessionPickerModal, conversationWidth, viewportHeight);
|
|
97
103
|
next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { Line } from '../types/grid.ts';
|
|
2
|
+
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
3
|
+
import type { McpWorkspace, McpWorkspaceRow } from '../input/mcp-workspace.ts';
|
|
4
|
+
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
5
|
+
|
|
6
|
+
const C = {
|
|
7
|
+
border: '#64748b',
|
|
8
|
+
title: '#67e8f9',
|
|
9
|
+
section: '#93c5fd',
|
|
10
|
+
text: '#e2e8f0',
|
|
11
|
+
muted: '#94a3b8',
|
|
12
|
+
dim: '#64748b',
|
|
13
|
+
selectedBg: '#223049',
|
|
14
|
+
panelBg: '#101720',
|
|
15
|
+
detailBg: '#111827',
|
|
16
|
+
good: '#22c55e',
|
|
17
|
+
warn: '#f59e0b',
|
|
18
|
+
bad: '#ef4444',
|
|
19
|
+
info: '#38bdf8',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Style = Partial<Omit<Line[number], 'char'>>;
|
|
23
|
+
|
|
24
|
+
function line(width: number, bg?: string): Line {
|
|
25
|
+
const out = createEmptyLine(width);
|
|
26
|
+
if (bg) {
|
|
27
|
+
for (let i = 0; i < out.length; i += 1) out[i] = createStyledCell(' ', { bg });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function write(out: Line, x: number, maxWidth: number, text: string, style: Style = {}): void {
|
|
33
|
+
let col = x;
|
|
34
|
+
let used = 0;
|
|
35
|
+
for (const ch of text) {
|
|
36
|
+
const w = getDisplayWidth(ch);
|
|
37
|
+
if (w <= 0) continue;
|
|
38
|
+
if (used + w > maxWidth || col >= out.length) break;
|
|
39
|
+
out[col] = createStyledCell(ch, style);
|
|
40
|
+
if (w > 1 && col + 1 < out.length) out[col + 1] = createStyledCell(' ', style);
|
|
41
|
+
col += w;
|
|
42
|
+
used += w;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hline(width: number, left: string, fill: string, right: string): Line {
|
|
47
|
+
const out = line(width);
|
|
48
|
+
if (width <= 0) return out;
|
|
49
|
+
out[0] = createStyledCell(left, { fg: C.border });
|
|
50
|
+
for (let i = 1; i < width - 1; i += 1) out[i] = createStyledCell(fill, { fg: C.border });
|
|
51
|
+
if (width > 1) out[width - 1] = createStyledCell(right, { fg: C.border });
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function content(width: number, bg = C.panelBg): Line {
|
|
56
|
+
const out = line(width, bg);
|
|
57
|
+
if (width > 0) out[0] = createStyledCell('|', { fg: C.border, bg });
|
|
58
|
+
if (width > 1) out[width - 1] = createStyledCell('|', { fg: C.border, bg });
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function putVertical(out: Line, x: number, bg = C.panelBg): void {
|
|
63
|
+
if (x <= 0 || x >= out.length - 1) return;
|
|
64
|
+
out[x] = createStyledCell('|', { fg: C.border, bg });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function statusColor(text: string): string {
|
|
68
|
+
if (text.includes('failed') || text.includes('Save failed') || text.includes('Remove failed')) return C.bad;
|
|
69
|
+
if (text.includes('attention') || text.includes('quarantine')) return C.warn;
|
|
70
|
+
return C.muted;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function connectedColor(connected: boolean): string {
|
|
74
|
+
return connected ? C.good : C.warn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rowLabel(row: McpWorkspaceRow): string {
|
|
78
|
+
if (row.type === 'server') return `${row.server.connected ? '[on] ' : '[off]'} ${row.server.name}`;
|
|
79
|
+
return `+ ${row.label}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowDetail(row: McpWorkspaceRow): string {
|
|
83
|
+
if (row.type === 'server') {
|
|
84
|
+
const server = row.server;
|
|
85
|
+
return `${server.role} / ${server.trustMode} / ${server.freshness} / ${server.source}`;
|
|
86
|
+
}
|
|
87
|
+
return row.detail;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderHeader(width: number, workspace: McpWorkspace): Line[] {
|
|
91
|
+
const top = hline(width, '+', '-', '+');
|
|
92
|
+
write(top, 2, Math.max(0, width - 4), ' MCP Workspace / Servers ', { fg: C.title, bold: true });
|
|
93
|
+
const state = workspace.mode === 'browse' ? 'Browser' : workspace.mode === 'form' ? 'Edit Server' : 'Confirm Remove';
|
|
94
|
+
write(top, Math.max(2, width - state.length - 4), state.length, state, { fg: C.section });
|
|
95
|
+
return [top];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderLeftRail(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
99
|
+
const rows = workspace.rows;
|
|
100
|
+
const out: Line[] = [];
|
|
101
|
+
const selected = workspace.selectedIndex;
|
|
102
|
+
const visible = Math.max(1, height - 2);
|
|
103
|
+
const start = rows.length <= visible ? 0 : Math.max(0, Math.min(selected - Math.floor(visible / 2), rows.length - visible));
|
|
104
|
+
const end = Math.min(rows.length, start + visible);
|
|
105
|
+
const title = line(width, C.detailBg);
|
|
106
|
+
write(title, 1, width - 2, 'Servers and actions', { fg: C.section, bold: true, bg: C.detailBg });
|
|
107
|
+
out.push(title);
|
|
108
|
+
if (start > 0) {
|
|
109
|
+
const above = line(width, C.panelBg);
|
|
110
|
+
write(above, 1, width - 2, `^ ${start} item(s) above`, { fg: C.dim, bg: C.panelBg });
|
|
111
|
+
out.push(above);
|
|
112
|
+
}
|
|
113
|
+
for (let index = start; index < end && out.length < height; index += 1) {
|
|
114
|
+
const row = rows[index]!;
|
|
115
|
+
const selectedRow = index === selected && workspace.mode === 'browse';
|
|
116
|
+
const bg = selectedRow ? C.selectedBg : C.panelBg;
|
|
117
|
+
const item = line(width, bg);
|
|
118
|
+
write(item, 1, 2, selectedRow ? '>' : ' ', { fg: C.text, bg, bold: selectedRow });
|
|
119
|
+
write(item, 3, width - 4, rowLabel(row), {
|
|
120
|
+
fg: row.type === 'server' ? connectedColor(row.server.connected) : C.info,
|
|
121
|
+
bg,
|
|
122
|
+
bold: selectedRow || row.type === 'action',
|
|
123
|
+
});
|
|
124
|
+
out.push(item);
|
|
125
|
+
if (row.type === 'server' && out.length < height) {
|
|
126
|
+
const detail = line(width, bg);
|
|
127
|
+
write(detail, 5, width - 6, rowDetail(row), { fg: C.muted, bg });
|
|
128
|
+
out.push(detail);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (end < rows.length && out.length < height) {
|
|
132
|
+
const below = line(width, C.panelBg);
|
|
133
|
+
write(below, 1, width - 2, `v ${rows.length - end} item(s) below`, { fg: C.dim, bg: C.panelBg });
|
|
134
|
+
out.push(below);
|
|
135
|
+
}
|
|
136
|
+
while (out.length < height) out.push(line(width, C.panelBg));
|
|
137
|
+
return out.slice(0, height);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function selectedDetailLines(workspace: McpWorkspace, width: number): string[] {
|
|
141
|
+
if (workspace.mode === 'form') {
|
|
142
|
+
const field = workspace.formFields[workspace.formIndex];
|
|
143
|
+
return [
|
|
144
|
+
workspace.editingServerName ? `Editing server: ${workspace.editingServerName}` : 'Adding an MCP server',
|
|
145
|
+
'This writes the selected project/global MCP config and reloads the runtime. External Claude/Desktop config files remain untouched.',
|
|
146
|
+
field ? `${field.label}: ${field.help}` : '',
|
|
147
|
+
'Use Up/Down to choose fields. Type to edit text fields. Left/Right cycles role and trust mode. Enter saves only on Save and reload.',
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
if (workspace.mode === 'delete-confirm') {
|
|
151
|
+
return [
|
|
152
|
+
`Remove configured server: ${workspace.editingServerName ?? '(unknown)'}`,
|
|
153
|
+
'This removes the selected writable project/global config entry and reloads MCP runtime state.',
|
|
154
|
+
'Press y to remove, n or Esc to cancel.',
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
const selected = workspace.selectedRow;
|
|
158
|
+
if (!selected) return ['No MCP rows available.'];
|
|
159
|
+
if (selected.type === 'action') {
|
|
160
|
+
return [selected.label, selected.detail];
|
|
161
|
+
}
|
|
162
|
+
const server = selected.server;
|
|
163
|
+
return [
|
|
164
|
+
server.name,
|
|
165
|
+
`Connected: ${server.connected ? 'yes' : 'no'} Source: ${server.source} Schema: ${server.freshness}`,
|
|
166
|
+
`Role: ${server.role} Trust: ${server.trustMode}`,
|
|
167
|
+
`Command: ${server.command ? `${server.command}${server.args?.length ? ` ${server.args.join(' ')}` : ''}` : '(runtime only; no launch config found)'}`,
|
|
168
|
+
`Allowed paths: ${server.allowedPaths.length > 0 ? server.allowedPaths.join(', ') : '(none)'}`,
|
|
169
|
+
`Allowed hosts: ${server.allowedHosts.length > 0 ? server.allowedHosts.join(', ') : '(none)'}`,
|
|
170
|
+
...(server.quarantineReason ? [`Quarantine: ${server.quarantineReason}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`] : []),
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderDetails(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
175
|
+
const out: Line[] = [];
|
|
176
|
+
const header = line(width, C.detailBg);
|
|
177
|
+
write(header, 1, width - 2, workspace.mode === 'browse' ? 'Selected MCP server' : 'MCP server form', {
|
|
178
|
+
fg: C.section,
|
|
179
|
+
bold: true,
|
|
180
|
+
bg: C.detailBg,
|
|
181
|
+
});
|
|
182
|
+
out.push(header);
|
|
183
|
+
const wrapped = selectedDetailLines(workspace, width - 4).flatMap((entry) => wrapText(entry, Math.max(1, width - 4)));
|
|
184
|
+
for (const text of wrapped) {
|
|
185
|
+
const next = line(width, C.detailBg);
|
|
186
|
+
write(next, 1, width - 2, text, { fg: C.text, bg: C.detailBg });
|
|
187
|
+
out.push(next);
|
|
188
|
+
if (out.length >= height) break;
|
|
189
|
+
}
|
|
190
|
+
while (out.length < height) out.push(line(width, C.detailBg));
|
|
191
|
+
return out.slice(0, height);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderForm(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
195
|
+
const out: Line[] = [];
|
|
196
|
+
const fields = workspace.formFields;
|
|
197
|
+
const labelWidth = Math.min(22, Math.max(14, Math.floor(width * 0.24)));
|
|
198
|
+
const visible = Math.max(1, height - 1);
|
|
199
|
+
const start = fields.length <= visible ? 0 : Math.max(0, Math.min(workspace.formIndex - Math.floor(visible / 2), fields.length - visible));
|
|
200
|
+
for (let index = start; index < fields.length && out.length < height; index += 1) {
|
|
201
|
+
const field = fields[index]!;
|
|
202
|
+
const selected = index === workspace.formIndex;
|
|
203
|
+
const bg = selected ? C.selectedBg : C.panelBg;
|
|
204
|
+
const row = line(width, bg);
|
|
205
|
+
write(row, 1, 2, selected ? '>' : ' ', { fg: C.text, bg, bold: selected });
|
|
206
|
+
write(row, 3, labelWidth, field.label, { fg: field.id === 'save' ? C.good : field.id === 'cancel' ? C.warn : C.muted, bg, bold: selected });
|
|
207
|
+
if (field.id === 'save' || field.id === 'cancel') {
|
|
208
|
+
write(row, labelWidth + 5, width - labelWidth - 6, field.help, { fg: C.muted, bg });
|
|
209
|
+
} else {
|
|
210
|
+
const display = field.value.length > 0 ? field.value : '(empty)';
|
|
211
|
+
write(row, labelWidth + 5, width - labelWidth - 6, display, { fg: field.editable ? C.text : C.info, bg, bold: selected });
|
|
212
|
+
}
|
|
213
|
+
out.push(row);
|
|
214
|
+
}
|
|
215
|
+
while (out.length < height) out.push(line(width, C.panelBg));
|
|
216
|
+
return out.slice(0, height);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderTools(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
220
|
+
const out: Line[] = [];
|
|
221
|
+
const server = workspace.selectedServer?.name;
|
|
222
|
+
const tools = server ? workspace.tools.filter((tool) => tool.serverName === server) : workspace.tools;
|
|
223
|
+
const header = line(width, C.panelBg);
|
|
224
|
+
const label = workspace.loadingTools
|
|
225
|
+
? 'Tools: loading...'
|
|
226
|
+
: server
|
|
227
|
+
? `Tools for ${server}: ${tools.length}`
|
|
228
|
+
: `Tools: ${tools.length}`;
|
|
229
|
+
write(header, 1, width - 2, label, { fg: C.section, bold: true, bg: C.panelBg });
|
|
230
|
+
out.push(header);
|
|
231
|
+
if (tools.length === 0) {
|
|
232
|
+
const empty = line(width, C.panelBg);
|
|
233
|
+
write(empty, 1, width - 2, workspace.loadingTools ? 'Loading tool list from connected MCP servers.' : 'No tools cached for the selected server. Press t to refresh.', { fg: C.muted, bg: C.panelBg });
|
|
234
|
+
out.push(empty);
|
|
235
|
+
} else {
|
|
236
|
+
for (const tool of tools.slice(0, Math.max(0, height - 1))) {
|
|
237
|
+
const row = line(width, C.panelBg);
|
|
238
|
+
write(row, 1, Math.min(34, width - 2), `${tool.serverName}:${tool.toolName}`, { fg: C.info, bg: C.panelBg });
|
|
239
|
+
write(row, Math.min(38, width - 2), Math.max(0, width - 40), tool.description ?? '', { fg: C.muted, bg: C.panelBg });
|
|
240
|
+
out.push(row);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
while (out.length < height) out.push(line(width, C.panelBg));
|
|
244
|
+
return out.slice(0, height);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderBody(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
248
|
+
const leftWidth = Math.min(42, Math.max(28, Math.floor(width * 0.28)));
|
|
249
|
+
const rightWidth = Math.max(10, width - leftWidth - 3);
|
|
250
|
+
const topHeight = Math.max(8, Math.min(14, Math.floor(height * 0.34)));
|
|
251
|
+
const bottomHeight = Math.max(1, height - topHeight - 1);
|
|
252
|
+
const left = renderLeftRail(workspace, leftWidth, height);
|
|
253
|
+
const details = renderDetails(workspace, rightWidth, topHeight);
|
|
254
|
+
const bottom = workspace.mode === 'form'
|
|
255
|
+
? renderForm(workspace, rightWidth, bottomHeight)
|
|
256
|
+
: renderTools(workspace, rightWidth, bottomHeight);
|
|
257
|
+
const right = [...details, line(rightWidth, C.panelBg), ...bottom].slice(0, height);
|
|
258
|
+
const out: Line[] = [];
|
|
259
|
+
for (let i = 0; i < height; i += 1) {
|
|
260
|
+
const row = content(width);
|
|
261
|
+
const leftRow = left[i] ?? line(leftWidth, C.panelBg);
|
|
262
|
+
const rightRow = right[i] ?? line(rightWidth, C.panelBg);
|
|
263
|
+
for (let x = 0; x < leftWidth && x + 1 < row.length; x += 1) row[x + 1] = leftRow[x] ?? createStyledCell(' ');
|
|
264
|
+
putVertical(row, leftWidth + 1);
|
|
265
|
+
for (let x = 0; x < rightWidth && leftWidth + 3 + x < row.length - 1; x += 1) {
|
|
266
|
+
row[leftWidth + 3 + x] = rightRow[x] ?? createStyledCell(' ');
|
|
267
|
+
}
|
|
268
|
+
out.push(row);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderFooter(width: number, workspace: McpWorkspace): Line[] {
|
|
274
|
+
const status = content(width, C.detailBg);
|
|
275
|
+
const statusText = workspace.lastError ? workspace.status : workspace.status;
|
|
276
|
+
write(status, 2, width - 4, statusText, { fg: statusColor(statusText), bg: C.detailBg });
|
|
277
|
+
const controls = content(width, C.detailBg);
|
|
278
|
+
const text = workspace.mode === 'form'
|
|
279
|
+
? 'Up/Down field Type edit Left/Right cycle Enter save/cancel row Esc back'
|
|
280
|
+
: workspace.mode === 'delete-confirm'
|
|
281
|
+
? 'y confirm n/Esc cancel'
|
|
282
|
+
: 'Up/Down choose Enter edit/action a add e edit d remove r reload t tools Esc close';
|
|
283
|
+
write(controls, 2, width - 4, text, { fg: C.muted, bg: C.detailBg });
|
|
284
|
+
return [status, controls, hline(width, '+', '-', '+')];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function renderMcpWorkspace(workspace: McpWorkspace, width: number, height: number): Line[] {
|
|
288
|
+
const safeWidth = Math.max(40, width);
|
|
289
|
+
const safeHeight = Math.max(12, height);
|
|
290
|
+
const bodyHeight = Math.max(1, safeHeight - 4);
|
|
291
|
+
const lines = [
|
|
292
|
+
...renderHeader(safeWidth, workspace),
|
|
293
|
+
...renderBody(workspace, safeWidth, bodyHeight),
|
|
294
|
+
...renderFooter(safeWidth, workspace),
|
|
295
|
+
];
|
|
296
|
+
return lines.slice(0, safeHeight);
|
|
297
|
+
}
|
|
@@ -155,7 +155,7 @@ export function createBootstrapCommandActions(
|
|
|
155
155
|
| 'openCommunicationPanel'
|
|
156
156
|
| 'openOrchestrationPanel'
|
|
157
157
|
| 'openCockpitPanel'
|
|
158
|
-
| '
|
|
158
|
+
| 'openMcpWorkspace'
|
|
159
159
|
| 'openSecurityPanel'
|
|
160
160
|
| 'openKnowledgePanel'
|
|
161
161
|
| 'openRemotePanel'
|
|
@@ -262,9 +262,7 @@ export function createBootstrapCommandActions(
|
|
|
262
262
|
openCockpitPanel: () => {
|
|
263
263
|
showPanel('cockpit');
|
|
264
264
|
},
|
|
265
|
-
|
|
266
|
-
showPanel('mcp');
|
|
267
|
-
},
|
|
265
|
+
openMcpWorkspace: () => unwiredShellAction('openMcpWorkspace'),
|
|
268
266
|
openSecurityPanel: () => {
|
|
269
267
|
showPanel('security');
|
|
270
268
|
},
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
loadLastConversation,
|
|
34
34
|
writeLastSessionPointer,
|
|
35
35
|
} from '@/runtime/index.ts';
|
|
36
|
-
import { startBackgroundProviderRegistration } from '@/runtime/index.ts';
|
|
36
|
+
import { scheduleBackgroundMcpDiscovery, startBackgroundProviderRegistration } from '@/runtime/index.ts';
|
|
37
37
|
import { restoreSavedModel } from '@/runtime/index.ts';
|
|
38
38
|
import { startExternalServices, type ExternalServicesHandle, type HostServiceStatus } from '@/runtime/index.ts';
|
|
39
39
|
import { getOrCreateCompanionToken, pruneStaleOperatorTokens } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
@@ -46,6 +46,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
46
46
|
import { DaemonServer } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
47
47
|
import { HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
48
48
|
import { createSafeHostServeFactory } from '../daemon/safe-serve.ts';
|
|
49
|
+
import { startMcpConfigAutoReload } from '../mcp/runtime-reload.ts';
|
|
49
50
|
|
|
50
51
|
type ExternalServiceFactories = NonNullable<Parameters<typeof startExternalServices>[4]>;
|
|
51
52
|
|
|
@@ -136,7 +137,7 @@ export type BootstrapContext = RuntimeContext & {
|
|
|
136
137
|
* 4. Runtime bus subscriptions (WRFC, subagent, hook bridge)
|
|
137
138
|
* 5. Providers, webhooks, PermissionManager, HookDispatcher
|
|
138
139
|
* 6. Orchestrator + AcpManager
|
|
139
|
-
* 7. MCP auto-connect + panel manager
|
|
140
|
+
* 7. MCP auto-connect + workspace/panel manager
|
|
140
141
|
* 8. Command registry + plugin init + CommandContext
|
|
141
142
|
* 9. Input handler wiring
|
|
142
143
|
* 10. Input history, splash options
|
|
@@ -513,6 +514,29 @@ export async function bootstrapRuntime(
|
|
|
513
514
|
shellPaths: services.shellPaths,
|
|
514
515
|
surfaceRoot: 'tui',
|
|
515
516
|
});
|
|
517
|
+
const mcpDiscovery = scheduleBackgroundMcpDiscovery({
|
|
518
|
+
mcpRegistry: services.mcpRegistry,
|
|
519
|
+
systemMessageRouter,
|
|
520
|
+
requestRender,
|
|
521
|
+
shellPaths: services.shellPaths,
|
|
522
|
+
surfaceRoot: 'tui',
|
|
523
|
+
});
|
|
524
|
+
bootstrapUnsubs.push(() => mcpDiscovery.stop());
|
|
525
|
+
const mcpAutoReload = startMcpConfigAutoReload({
|
|
526
|
+
roots: services.shellPaths,
|
|
527
|
+
registry: services.mcpRegistry,
|
|
528
|
+
onReload: ({ connected, total }) => {
|
|
529
|
+
systemMessageRouter.low(`[MCP] Reloaded config: ${connected}/${total} server(s) connected.`);
|
|
530
|
+
requestRender();
|
|
531
|
+
},
|
|
532
|
+
onError: (error) => {
|
|
533
|
+
const message = summarizeError(error);
|
|
534
|
+
logger.warn('MCP config auto-reload failed', { error: message });
|
|
535
|
+
systemMessageRouter.high(`[MCP] Config reload failed: ${message}`);
|
|
536
|
+
requestRender();
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
bootstrapUnsubs.push(() => mcpAutoReload.stop());
|
|
516
540
|
if (configManager.get('automation.enabled')) {
|
|
517
541
|
deferredStartup.schedule({
|
|
518
542
|
label: 'automation',
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -279,6 +279,11 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
279
279
|
render();
|
|
280
280
|
};
|
|
281
281
|
|
|
282
|
+
commandContext.openMcpWorkspace = () => {
|
|
283
|
+
input.openMcpWorkspace(commandContext);
|
|
284
|
+
render();
|
|
285
|
+
};
|
|
286
|
+
|
|
282
287
|
commandContext.openSessionPicker = () => {
|
|
283
288
|
input.modalOpened('sessionPicker');
|
|
284
289
|
input.sessionPickerModal.open();
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '0.19.
|
|
9
|
+
let _version = '0.19.99';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|
package/src/panels/mcp-panel.ts
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import type { Line } from '../types/grid.ts';
|
|
2
|
-
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
3
|
-
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
|
|
4
|
-
import type { McpDecisionRecord } from '@/runtime/index.ts';
|
|
5
|
-
|
|
6
|
-
type McpServerSecurityEntry = ReturnType<McpRegistry['listServerSecurity']>[number];
|
|
7
|
-
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
8
|
-
import {
|
|
9
|
-
buildGuidanceLine,
|
|
10
|
-
buildKeyValueLine,
|
|
11
|
-
buildPanelLine,
|
|
12
|
-
buildStatusPill,
|
|
13
|
-
DEFAULT_PANEL_PALETTE,
|
|
14
|
-
} from './polish.ts';
|
|
15
|
-
|
|
16
|
-
const C = {
|
|
17
|
-
...DEFAULT_PANEL_PALETTE,
|
|
18
|
-
header: '#94a3b8',
|
|
19
|
-
headerBg: '#1e293b',
|
|
20
|
-
ok: '#22c55e',
|
|
21
|
-
warn: '#eab308',
|
|
22
|
-
error: '#ef4444',
|
|
23
|
-
selectBg: '#0f172a',
|
|
24
|
-
} as const;
|
|
25
|
-
|
|
26
|
-
function modeColor(mode: string): string {
|
|
27
|
-
switch (mode) {
|
|
28
|
-
case 'allow-all':
|
|
29
|
-
return C.error;
|
|
30
|
-
case 'ask-on-risk':
|
|
31
|
-
return C.warn;
|
|
32
|
-
case 'constrained':
|
|
33
|
-
return C.ok;
|
|
34
|
-
case 'blocked':
|
|
35
|
-
return C.error;
|
|
36
|
-
default:
|
|
37
|
-
return C.dim;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function freshnessColor(freshness: string): string {
|
|
42
|
-
switch (freshness) {
|
|
43
|
-
case 'fresh':
|
|
44
|
-
return C.ok;
|
|
45
|
-
case 'stale':
|
|
46
|
-
case 'fetch_failed':
|
|
47
|
-
return C.warn;
|
|
48
|
-
case 'quarantined':
|
|
49
|
-
return C.error;
|
|
50
|
-
default:
|
|
51
|
-
return C.dim;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function decisionColor(decision: McpDecisionRecord): string {
|
|
56
|
-
if (decision.verdict === 'deny') return C.error;
|
|
57
|
-
if (decision.verdict === 'ask') return C.warn;
|
|
58
|
-
return decision.incoherent ? C.warn : C.ok;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export class McpPanel extends ScrollableListPanel<McpServerSecurityEntry> {
|
|
62
|
-
private readonly registry: McpRegistry;
|
|
63
|
-
|
|
64
|
-
public constructor(registry: McpRegistry) {
|
|
65
|
-
super('mcp', 'MCP', 'Z', 'monitoring');
|
|
66
|
-
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
67
|
-
this.registry = registry;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
protected override getPalette() { return C; }
|
|
71
|
-
protected override getEmptyStateMessage() { return ' No MCP servers configured or connected.'; }
|
|
72
|
-
protected override getEmptyStateActions() {
|
|
73
|
-
return [
|
|
74
|
-
{ command: '/mcp', summary: 'list server state and security posture' },
|
|
75
|
-
{ command: '/settings', summary: 'open the MCP settings category for trust and scope controls' },
|
|
76
|
-
];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
protected getItems(): readonly McpServerSecurityEntry[] {
|
|
80
|
-
return this.registry.listServerSecurity();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
protected renderItem(entry: McpServerSecurityEntry, index: number, selected: boolean, width: number): Line {
|
|
84
|
-
const bg = selected ? C.selectBg : undefined;
|
|
85
|
-
return buildPanelLine(width, [
|
|
86
|
-
[' ', C.label, bg],
|
|
87
|
-
[entry.name.padEnd(20), C.value, bg],
|
|
88
|
-
...buildStatusPill(entry.connected ? 'good' : 'bad', ` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, { bg }),
|
|
89
|
-
[` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
|
|
90
|
-
[` ${entry.role.padEnd(10)}`, C.info, bg],
|
|
91
|
-
[` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],
|
|
92
|
-
]);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
public handleInput(key: string): boolean {
|
|
96
|
-
if (key === 'r') {
|
|
97
|
-
this.markDirty();
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
return super.handleInput(key);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
public render(width: number, height: number): Line[] {
|
|
104
|
-
this.clampSelection();
|
|
105
|
-
const entries = this.registry.listServerSecurity();
|
|
106
|
-
const intro = 'Trust, quarantine, scope, and recent security decisions for configured MCP servers.';
|
|
107
|
-
|
|
108
|
-
const connected = entries.filter((e) => e.connected).length;
|
|
109
|
-
const quarantined = entries.filter((e) => e.schemaFreshness === 'quarantined').length;
|
|
110
|
-
const disconnected = entries.length - connected;
|
|
111
|
-
const staleSchemas = entries.filter((e) => e.schemaFreshness !== 'fresh').length;
|
|
112
|
-
|
|
113
|
-
const headerLines: Line[] = [
|
|
114
|
-
buildPanelLine(width, [[' MCP posture', C.label]]),
|
|
115
|
-
buildKeyValueLine(width, [
|
|
116
|
-
{ label: 'servers', value: String(entries.length), valueColor: C.value },
|
|
117
|
-
{ label: 'connected', value: String(connected), valueColor: connected > 0 ? C.ok : C.dim },
|
|
118
|
-
{ label: 'disconnected', value: String(disconnected), valueColor: disconnected > 0 ? C.warn : C.dim },
|
|
119
|
-
{ label: 'stale schema', value: String(staleSchemas), valueColor: staleSchemas > 0 ? C.warn : C.dim },
|
|
120
|
-
{ label: 'quarantined', value: String(quarantined), valueColor: quarantined > 0 ? C.error : C.dim },
|
|
121
|
-
], C),
|
|
122
|
-
buildGuidanceLine(width, '/mcp review', 'inspect trust, freshness, and quarantine posture for configured servers', C),
|
|
123
|
-
buildGuidanceLine(width, '/mcp repair', 'review reconnect, auth, import, and startup remediation guidance', C),
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const selected = entries[this.selectedIndex];
|
|
127
|
-
const detailLines: Line[] = [];
|
|
128
|
-
const repairLines: Line[] = [];
|
|
129
|
-
|
|
130
|
-
if (selected) {
|
|
131
|
-
const sandboxBinding = this.registry.listServerSandboxBindings().find((e) => e.name === selected.name);
|
|
132
|
-
const decisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
|
|
133
|
-
const selectedDecision = decisions.find((d) => d.serverName === selected.name);
|
|
134
|
-
|
|
135
|
-
detailLines.push(buildPanelLine(width, [
|
|
136
|
-
[' Server: ', C.label],
|
|
137
|
-
[selected.name, C.value],
|
|
138
|
-
[' Trust: ', C.label],
|
|
139
|
-
[selected.trustMode, modeColor(selected.trustMode)],
|
|
140
|
-
[' Role: ', C.label],
|
|
141
|
-
[selected.role, C.info],
|
|
142
|
-
]));
|
|
143
|
-
detailLines.push(buildPanelLine(width, [
|
|
144
|
-
[' Schema: ', C.label],
|
|
145
|
-
[selected.schemaFreshness, freshnessColor(selected.schemaFreshness)],
|
|
146
|
-
[' Approved by: ', C.label],
|
|
147
|
-
[truncateDisplay(selected.quarantineApprovedBy ?? 'n/a', Math.max(0, width - 31)), selected.quarantineApprovedBy ? C.info : C.dim],
|
|
148
|
-
]));
|
|
149
|
-
detailLines.push(buildPanelLine(width, [
|
|
150
|
-
[' Scope: ', C.label],
|
|
151
|
-
[truncateDisplay(
|
|
152
|
-
`paths ${selected.allowedPaths.length > 0 ? selected.allowedPaths.join(', ') : 'unbounded'} hosts ${selected.allowedHosts.length > 0 ? selected.allowedHosts.join(', ') : 'unbounded'}`,
|
|
153
|
-
Math.max(0, width - 10),
|
|
154
|
-
), (selected.allowedPaths.length > 0 || selected.allowedHosts.length > 0) ? C.value : C.dim],
|
|
155
|
-
]));
|
|
156
|
-
detailLines.push(buildPanelLine(width, [
|
|
157
|
-
[' Sandbox: ', C.label],
|
|
158
|
-
[truncateDisplay(
|
|
159
|
-
sandboxBinding?.sessionId
|
|
160
|
-
? `${sandboxBinding.profileId ?? 'mcp'} ${sandboxBinding.state ?? 'unknown'} ${sandboxBinding.backend ?? 'n/a'} ${sandboxBinding.startupStatus ?? 'n/a'} (${sandboxBinding.sessionId})`
|
|
161
|
-
: 'not isolated',
|
|
162
|
-
Math.max(0, width - 13),
|
|
163
|
-
), sandboxBinding?.sessionId ? C.info : C.dim],
|
|
164
|
-
]));
|
|
165
|
-
if (selected.schemaFreshness === 'quarantined') {
|
|
166
|
-
detailLines.push(buildPanelLine(width, [
|
|
167
|
-
[' Quarantine: ', C.label],
|
|
168
|
-
[truncateDisplay(`${selected.quarantineReason ?? 'unknown'}${selected.quarantineDetail ? ` - ${selected.quarantineDetail}` : ''}`, Math.max(0, width - 15)), C.error],
|
|
169
|
-
]));
|
|
170
|
-
}
|
|
171
|
-
if (selectedDecision) {
|
|
172
|
-
const summary = `${selectedDecision.serverName}:${selectedDecision.toolName} ${selectedDecision.verdict.toUpperCase()} ${selectedDecision.capability}${selectedDecision.incoherent ? ' incoherent' : ''}`;
|
|
173
|
-
detailLines.push(buildPanelLine(width, [
|
|
174
|
-
[' Recent: ', C.label],
|
|
175
|
-
[truncateDisplay(summary, Math.max(0, width - 10)), decisionColor(selectedDecision)],
|
|
176
|
-
]));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!selected.connected) {
|
|
180
|
-
repairLines.push(buildPanelLine(width, [[' /mcp repair', C.warn], [' review reconnect and startup posture for this server', C.dim]]));
|
|
181
|
-
}
|
|
182
|
-
if (selected.schemaFreshness !== 'fresh') {
|
|
183
|
-
repairLines.push(buildPanelLine(width, [[' /mcp review', C.warn], [' inspect schema freshness, quarantine, and trust posture', C.dim]]));
|
|
184
|
-
}
|
|
185
|
-
if (sandboxBinding?.sessionId) {
|
|
186
|
-
repairLines.push(buildPanelLine(width, [[' /sandbox review', C.info], [' verify the bound MCP isolation session and startup status', C.dim]]));
|
|
187
|
-
}
|
|
188
|
-
if (repairLines.length === 0) {
|
|
189
|
-
repairLines.push(buildPanelLine(width, [[' No immediate MCP repair actions suggested for the selected server.', C.dim]]));
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const allDecisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
|
|
193
|
-
const decisionLines: Line[] = allDecisions.length === 0
|
|
194
|
-
? [buildPanelLine(width, [[' No MCP decisions recorded yet.', C.dim]])]
|
|
195
|
-
: allDecisions.map((decision) => {
|
|
196
|
-
const summary = `${decision.serverName}:${decision.toolName} ${decision.verdict.toUpperCase()} ${decision.capability}${decision.incoherent ? ' incoherent' : ''}`;
|
|
197
|
-
return buildPanelLine(width, [
|
|
198
|
-
[' ', C.label],
|
|
199
|
-
[truncateDisplay(summary, Math.max(0, width - 2)), decisionColor(decision)],
|
|
200
|
-
]);
|
|
201
|
-
});
|
|
202
|
-
detailLines.push(...repairLines);
|
|
203
|
-
detailLines.push(...decisionLines);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return this.renderList(width, height, {
|
|
207
|
-
title: 'MCP Control Room',
|
|
208
|
-
header: headerLines,
|
|
209
|
-
footer: [
|
|
210
|
-
...detailLines,
|
|
211
|
-
buildPanelLine(width, [[' Up/Down move r refresh', C.dim]]),
|
|
212
|
-
],
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
}
|