@pellux/goodvibes-tui 0.20.0 → 0.20.1

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.
@@ -1,297 +1,237 @@
1
- import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
1
  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
- }
2
+ import type { Line } from '../types/grid.ts';
3
+ import { wrapText } from '../utils/terminal-width.ts';
4
+ import { GLYPHS } from './ui-primitives.ts';
5
+ import {
6
+ getFullscreenWorkspaceMetrics,
7
+ padDisplay,
8
+ renderFullscreenWorkspace,
9
+ stableWindow,
10
+ WORKSPACE_PALETTE as PALETTE,
11
+ type WorkspaceRow,
12
+ } from './fullscreen-workspace.ts';
66
13
 
67
14
  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;
15
+ if (text.includes('failed') || text.includes('Save failed') || text.includes('Remove failed')) return PALETTE.bad;
16
+ if (text.includes('attention') || text.includes('quarantine')) return PALETTE.warn;
17
+ return PALETTE.muted;
71
18
  }
72
19
 
73
20
  function connectedColor(connected: boolean): string {
74
- return connected ? C.good : C.warn;
21
+ return connected ? PALETTE.good : PALETTE.warn;
75
22
  }
76
23
 
77
24
  function rowLabel(row: McpWorkspaceRow): string {
78
- if (row.type === 'server') return `${row.server.connected ? '[on] ' : '[off]'} ${row.server.name}`;
79
- return `+ ${row.label}`;
25
+ if (row.type === 'server') return `${row.server.name} (${row.server.source})`;
26
+ return row.label;
80
27
  }
81
28
 
82
29
  function rowDetail(row: McpWorkspaceRow): string {
83
30
  if (row.type === 'server') {
84
31
  const server = row.server;
85
- return `${server.role} / ${server.trustMode} / ${server.freshness} / ${server.source}`;
32
+ return `${server.connected ? 'connected' : 'offline'} · ${server.role} · ${server.trustMode} · schema ${server.freshness}`;
86
33
  }
87
34
  return row.detail;
88
35
  }
89
36
 
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
- }
37
+ function buildLeftRows(workspace: McpWorkspace, height: number): WorkspaceRow[] {
38
+ const rendered: WorkspaceRow[] = [];
39
+ let selectedRenderedIndex = 0;
40
+ let sawServerGroup = false;
41
+ let sawActionGroup = false;
97
42
 
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);
43
+ workspace.rows.forEach((row, rowIndex) => {
44
+ if (row.type === 'server' && !sawServerGroup) {
45
+ rendered.push({ text: 'SERVERS', kind: 'group', bold: true });
46
+ sawServerGroup = true;
129
47
  }
48
+ if (row.type === 'action' && !sawActionGroup) {
49
+ if (!sawServerGroup) rendered.push({ text: 'SERVERS', kind: 'group', bold: true });
50
+ if (workspace.servers.length === 0) rendered.push({ text: ' No configured servers', kind: 'item', fg: PALETTE.dim, dim: true });
51
+ rendered.push({ text: 'ACTIONS', kind: 'group', bold: true });
52
+ sawActionGroup = true;
53
+ }
54
+
55
+ const selected = workspace.mode === 'browse' && rowIndex === workspace.selectedIndex;
56
+ if (selected) selectedRenderedIndex = rendered.length;
57
+ const marker = selected ? GLYPHS.navigation.selected : row.type === 'server' ? (row.server.connected ? '✓' : '•') : '+';
58
+ rendered.push({
59
+ text: ` ${marker} ${rowLabel(row)}`,
60
+ selected,
61
+ kind: 'item',
62
+ fg: row.type === 'server' ? connectedColor(row.server.connected) : PALETTE.info,
63
+ bold: selected || row.type === 'action',
64
+ });
65
+ });
66
+
67
+ const visible = Math.max(1, height);
68
+ const window = stableWindow(rendered.length, selectedRenderedIndex, visible);
69
+ const rows = rendered.slice(window.start, window.end);
70
+ if (window.start > 0 && rows.length > 0) {
71
+ rows[0] = { text: `${GLYPHS.navigation.moreAbove} ${window.start} more row(s) above`, kind: 'more', fg: PALETTE.dim, dim: true };
130
72
  }
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);
73
+ if (window.end < rendered.length && rows.length > 0) {
74
+ rows[rows.length - 1] = { text: `${GLYPHS.navigation.moreBelow} ${rendered.length - window.end} more row(s) below`, kind: 'more', fg: PALETTE.dim, dim: true };
135
75
  }
136
- while (out.length < height) out.push(line(width, C.panelBg));
137
- return out.slice(0, height);
76
+ while (rows.length < height) rows.push({ text: '', kind: 'empty' });
77
+ return rows.slice(0, height);
138
78
  }
139
79
 
140
- function selectedDetailLines(workspace: McpWorkspace, width: number): string[] {
80
+ function selectedDetailLines(workspace: McpWorkspace, width: number): WorkspaceRow[] {
81
+ const lines: string[] = [];
141
82
  if (workspace.mode === 'form') {
142
83
  const field = workspace.formFields[workspace.formIndex];
143
- return [
84
+ lines.push(
144
85
  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.',
86
+ 'Write a server through the SDK MCP config manager, then reload the live runtime without restarting the TUI.',
146
87
  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 [
88
+ 'Project scope writes to this workspace. Global scope writes to your user MCP config. External Claude/Desktop config files are shown but not edited here.',
89
+ );
90
+ } else if (workspace.mode === 'delete-confirm') {
91
+ lines.push(
152
92
  `Remove configured server: ${workspace.editingServerName ?? '(unknown)'}`,
153
93
  'This removes the selected writable project/global config entry and reloads MCP runtime state.',
154
94
  '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];
95
+ );
96
+ } else {
97
+ const selected = workspace.selectedRow;
98
+ if (!selected) lines.push('No MCP rows available.');
99
+ else if (selected.type === 'action') lines.push(selected.label, selected.detail);
100
+ else {
101
+ const server = selected.server;
102
+ lines.push(
103
+ server.name,
104
+ `Connected: ${server.connected ? 'yes' : 'no'} Source: ${server.source} Schema: ${server.freshness}`,
105
+ `Role: ${server.role} Trust: ${server.trustMode}`,
106
+ `Command: ${server.command ? `${server.command}${server.args?.length ? ` ${server.args.join(' ')}` : ''}` : '(runtime only; no launch config found)'}`,
107
+ `Allowed paths: ${server.allowedPaths.length > 0 ? server.allowedPaths.join(', ') : '(none)'}`,
108
+ `Allowed hosts: ${server.allowedHosts.length > 0 ? server.allowedHosts.join(', ') : '(none)'}`,
109
+ ...(server.quarantineReason ? [`Quarantine: ${server.quarantineReason}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`] : []),
110
+ );
111
+ }
161
112
  }
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
113
 
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,
114
+ lines.push('', `Status: ${workspace.status}`);
115
+ return lines.flatMap((text, index): WorkspaceRow[] => {
116
+ if (text === '') return [{ text: '', dim: true }];
117
+ return wrapText(text, Math.max(1, width)).map((wrapped, wrapIndex): WorkspaceRow => ({
118
+ text: wrapped,
119
+ fg: index === 0 ? PALETTE.title : text.startsWith('Status:') ? statusColor(workspace.status) : PALETTE.text,
120
+ bold: index === 0 && wrapIndex === 0,
121
+ dim: text.length === 0,
122
+ }));
181
123
  });
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
124
  }
193
125
 
194
- function renderForm(workspace: McpWorkspace, width: number, height: number): Line[] {
195
- const out: Line[] = [];
126
+ function buildFormRows(workspace: McpWorkspace, width: number, height: number): WorkspaceRow[] {
127
+ const rows: WorkspaceRow[] = [];
196
128
  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) {
129
+ const labelWidth = Math.min(24, Math.max(14, Math.floor(width * 0.24)));
130
+ const valueWidth = Math.max(12, width - labelWidth - 20);
131
+ rows.push({
132
+ text: ` ${padDisplay('Field', labelWidth)} ${padDisplay('Value', valueWidth)} ${padDisplay('Edit', 10)}`,
133
+ fg: PALETTE.muted,
134
+ bold: true,
135
+ });
136
+
137
+ const visible = Math.max(1, height - 2);
138
+ const window = stableWindow(fields.length, workspace.formIndex, visible);
139
+ if (window.start > 0) rows.push({ text: `${GLYPHS.navigation.moreAbove} ${window.start} more field(s) above`, kind: 'more', fg: PALETTE.dim, dim: true });
140
+ for (let index = window.start; index < window.end; index += 1) {
201
141
  const field = fields[index]!;
202
142
  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);
143
+ const marker = selected ? GLYPHS.navigation.selected : ' ';
144
+ const value = field.id === 'save' || field.id === 'cancel'
145
+ ? field.help
146
+ : field.value.length > 0 ? field.value : '(empty)';
147
+ rows.push({
148
+ text: `${marker} ${padDisplay(field.label, labelWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(field.editable ? 'text' : 'cycle/action', 12)}`,
149
+ selected,
150
+ fg: field.id === 'save' ? PALETTE.good : field.id === 'cancel' ? PALETTE.warn : field.editable ? PALETTE.text : PALETTE.info,
151
+ bold: selected,
152
+ });
214
153
  }
215
- while (out.length < height) out.push(line(width, C.panelBg));
216
- return out.slice(0, height);
154
+ if (window.end < fields.length) rows.push({ text: `${GLYPHS.navigation.moreBelow} ${fields.length - window.end} more field(s) below`, kind: 'more', fg: PALETTE.dim, dim: true });
155
+ while (rows.length < height) rows.push({ text: '', kind: 'empty' });
156
+ return rows.slice(0, height);
217
157
  }
218
158
 
219
- function renderTools(workspace: McpWorkspace, width: number, height: number): Line[] {
220
- const out: Line[] = [];
159
+ function buildToolRows(workspace: McpWorkspace, width: number, height: number): WorkspaceRow[] {
221
160
  const server = workspace.selectedServer?.name;
222
161
  const tools = server ? workspace.tools.filter((tool) => tool.serverName === server) : workspace.tools;
223
- const header = line(width, C.panelBg);
162
+ const toolWidth = Math.min(36, Math.max(18, Math.floor(width * 0.34)));
163
+ const serverWidth = Math.min(24, Math.max(12, Math.floor(width * 0.20)));
164
+ const descriptionWidth = Math.max(12, width - toolWidth - serverWidth - 8);
224
165
  const label = workspace.loadingTools
225
166
  ? 'Tools: loading...'
226
167
  : server
227
168
  ? `Tools for ${server}: ${tools.length}`
228
169
  : `Tools: ${tools.length}`;
229
- write(header, 1, width - 2, label, { fg: C.section, bold: true, bg: C.panelBg });
230
- out.push(header);
170
+ const rows: WorkspaceRow[] = [
171
+ { text: label, fg: PALETTE.subtitle, bold: true },
172
+ { text: ` ${padDisplay('Tool', toolWidth)} ${padDisplay('Server', serverWidth)} ${padDisplay('Description', descriptionWidth)}`, fg: PALETTE.muted, bold: true },
173
+ ];
174
+
231
175
  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);
176
+ rows.push({
177
+ text: workspace.loadingTools ? 'Loading tool list from connected MCP servers.' : 'No tools cached for the selected server. Press t to refresh.',
178
+ fg: PALETTE.muted,
179
+ dim: true,
180
+ });
235
181
  } 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);
182
+ for (const tool of tools.slice(0, Math.max(0, height - rows.length))) {
183
+ rows.push({
184
+ text: ` ${padDisplay(tool.toolName, toolWidth)} ${padDisplay(tool.serverName, serverWidth)} ${padDisplay(tool.description ?? '', descriptionWidth)}`,
185
+ fg: PALETTE.text,
186
+ });
241
187
  }
242
188
  }
243
- while (out.length < height) out.push(line(width, C.panelBg));
244
- return out.slice(0, height);
189
+
190
+ while (rows.length < height) rows.push({ text: '', kind: 'empty' });
191
+ return rows.slice(0, height);
245
192
  }
246
193
 
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;
194
+ function buildDeleteRows(workspace: McpWorkspace, height: number): WorkspaceRow[] {
195
+ const rows: WorkspaceRow[] = [
196
+ { text: `${GLYPHS.navigation.selected} Confirm remove ${workspace.editingServerName ?? '(unknown)'}`, selected: true, fg: PALETTE.bad, bold: true },
197
+ { text: ' Cancel and return to MCP server browser', fg: PALETTE.muted },
198
+ ];
199
+ while (rows.length < height) rows.push({ text: '', kind: 'empty' });
200
+ return rows.slice(0, height);
271
201
  }
272
202
 
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, '+', '-', '+')];
203
+ function buildControlRows(workspace: McpWorkspace, width: number, height: number): WorkspaceRow[] {
204
+ if (workspace.mode === 'form') return buildFormRows(workspace, width, height);
205
+ if (workspace.mode === 'delete-confirm') return buildDeleteRows(workspace, height);
206
+ return buildToolRows(workspace, width, height);
207
+ }
208
+
209
+ function footerText(workspace: McpWorkspace): string {
210
+ if (workspace.mode === 'form') return 'Focus server form · Up/Down field · Left/Right cycle · Type edit · Enter save/cancel row · Esc back';
211
+ if (workspace.mode === 'delete-confirm') return 'Focus remove confirmation · y confirm · n/Esc cancel';
212
+ return 'Focus MCP workspace · Up/Down choose · Enter edit/action · a add · d remove · r reload · t tools · Esc close';
285
213
  }
286
214
 
287
215
  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);
216
+ const metrics = getFullscreenWorkspaceMetrics({ width, height });
217
+ const connected = workspace.servers.filter((server) => server.connected).length;
218
+ const stateLabel = workspace.mode === 'browse' ? 'Browse' : workspace.mode === 'form' ? 'Edit Server' : 'Confirm Remove';
219
+ const mainHeader = workspace.mode === 'form'
220
+ ? 'MCP server form'
221
+ : workspace.mode === 'delete-confirm'
222
+ ? 'Remove MCP server'
223
+ : `Servers ${connected}/${workspace.servers.length} connected · Tools ${workspace.tools.length}`;
224
+
225
+ return renderFullscreenWorkspace({
226
+ width,
227
+ height,
228
+ title: 'MCP Workspace / Servers',
229
+ stateLabel,
230
+ leftHeader: 'Servers',
231
+ mainHeader,
232
+ leftRows: buildLeftRows(workspace, metrics.bodyRows),
233
+ contextRows: selectedDetailLines(workspace, metrics.contextWidth),
234
+ controlRows: buildControlRows(workspace, metrics.contextWidth, metrics.controlRows),
235
+ footer: footerText(workspace),
236
+ });
297
237
  }
@@ -1,5 +1,4 @@
1
1
  import type { Line } from '../../types/grid.ts';
2
- import { createStyledCell } from '../../types/grid.ts';
3
2
  import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText } from '../../utils/terminal-width.ts';
4
3
  import {
5
4
  createOverlayBoxLayout,
@@ -9,6 +8,7 @@ import {
9
8
  OVERLAY_GLYPHS,
10
9
  putOverlayText,
11
10
  } from '../overlay-box.ts';
11
+ import { clamp, drawVerticalRule, fillWidth } from '../fullscreen-primitives.ts';
12
12
  import { UI_TONES } from '../ui-primitives.ts';
13
13
  import {
14
14
  getOnboardingWizardBodyRows,
@@ -28,32 +28,6 @@ type RenderedFieldRow =
28
28
  readonly absoluteIndex: number;
29
29
  };
30
30
 
31
- function clamp(value: number, min: number, max: number): number {
32
- return Math.max(min, Math.min(max, value));
33
- }
34
-
35
- function fillRange(line: Line, startX: number, width: number, bg: string): void {
36
- for (let x = startX; x < Math.min(line.length, startX + width); x += 1) {
37
- const cell = line[x];
38
- if (!cell) continue;
39
- line[x] = createStyledCell(cell.char, {
40
- fg: cell.fg,
41
- bg,
42
- bold: cell.bold,
43
- dim: cell.dim,
44
- underline: cell.underline,
45
- italic: cell.italic,
46
- strikethrough: cell.strikethrough,
47
- link: cell.link,
48
- });
49
- }
50
- }
51
-
52
- function drawVerticalRule(line: Line, x: number, fg: string, bg = ''): void {
53
- if (x < 0 || x >= line.length) return;
54
- line[x] = createStyledCell('│', { fg, bg });
55
- }
56
-
57
31
  function modeLabel(mode: OnboardingWizardController['mode']): string {
58
32
  if (mode === 'edit') return 'Edit existing';
59
33
  if (mode === 'reopen') return 'Reopen review';
@@ -250,7 +224,7 @@ function renderFieldRow(
250
224
  const selected = fieldRow.absoluteIndex === wizard.getSelectedFieldIndex();
251
225
  const field = fieldRow.field;
252
226
  const fieldBg = selected ? DEFAULT_OVERLAY_PALETTE.selectedBg : UI_TONES.bg.base;
253
- fillRange(line, startX, width, fieldBg);
227
+ fillWidth(line, startX, width, fieldBg);
254
228
 
255
229
  const badge = truncateDisplay(`[${wizard.getFieldValueLabel(field)}]`, Math.max(8, Math.floor(width * 0.34)));
256
230
  const badgeWidth = getDisplayWidth(badge);
@@ -354,9 +328,9 @@ function renderWideLayout(
354
328
  lines.push(topLine);
355
329
 
356
330
  const headerLine = createOverlayContentLine(width, layout, borderFg, headerBg);
357
- fillRange(headerLine, leftStart, leftWidth, railBg);
358
- fillRange(headerLine, centerStart, centerWidth, headerBg);
359
- fillRange(headerLine, rightStart, rightWidth, summaryBg);
331
+ fillWidth(headerLine, leftStart, leftWidth, railBg);
332
+ fillWidth(headerLine, centerStart, centerWidth, headerBg);
333
+ fillWidth(headerLine, rightStart, rightWidth, summaryBg);
360
334
  drawVerticalRule(headerLine, leftSeparatorX, borderFg, headerBg);
361
335
  drawVerticalRule(headerLine, rightSeparatorX, borderFg, headerBg);
362
336
  putOverlayText(headerLine, leftStart + 1, leftWidth - 2, 'Steps', {
@@ -388,9 +362,9 @@ function renderWideLayout(
388
362
 
389
363
  for (let row = 0; row < bodyRows; row += 1) {
390
364
  const line = createOverlayContentLine(width, layout, borderFg, bodyBg);
391
- fillRange(line, leftStart, leftWidth, railBg);
392
- fillRange(line, centerStart, centerWidth, bodyBg);
393
- fillRange(line, rightStart, rightWidth, summaryBg);
365
+ fillWidth(line, leftStart, leftWidth, railBg);
366
+ fillWidth(line, centerStart, centerWidth, bodyBg);
367
+ fillWidth(line, rightStart, rightWidth, summaryBg);
394
368
  drawVerticalRule(line, leftSeparatorX, borderFg);
395
369
  drawVerticalRule(line, rightSeparatorX, borderFg);
396
370
 
@@ -411,20 +385,20 @@ function renderWideLayout(
411
385
  bg: bodyBg,
412
386
  });
413
387
  } else if (row === 3) {
414
- fillRange(line, centerStart, centerWidth, railBg);
388
+ fillWidth(line, centerStart, centerWidth, railBg);
415
389
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(controlsText(wizard), centerWidth - 2), {
416
390
  fg: UI_TONES.state.info,
417
391
  bg: railBg,
418
392
  });
419
393
  } else if (row === 4) {
420
- fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
394
+ fillWidth(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
421
395
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, centerWidth - 2), {
422
396
  fg: UI_TONES.fg.primary,
423
397
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
424
398
  bold: true,
425
399
  });
426
400
  } else if (row === 5) {
427
- fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
401
+ fillWidth(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
428
402
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(selectedText.hint, centerWidth - 2), {
429
403
  fg: UI_TONES.fg.secondary,
430
404
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
@@ -534,7 +508,7 @@ function renderCollapsedLayout(
534
508
  lines.push(topLine);
535
509
 
536
510
  const headerLine = createOverlayContentLine(width, layout, borderFg, headerBg);
537
- fillRange(headerLine, innerStart, innerWidth, headerBg);
511
+ fillWidth(headerLine, innerStart, innerWidth, headerBg);
538
512
  putOverlayText(headerLine, innerStart + 1, innerWidth - 2, fitDisplay(`${modeLabel(wizard.mode)} • ${currentStep.shortLabel}`, innerWidth - 2), {
539
513
  fg: UI_TONES.state.active,
540
514
  bg: headerBg,
@@ -554,7 +528,7 @@ function renderCollapsedLayout(
554
528
 
555
529
  for (let row = 0; row < bodyRows; row += 1) {
556
530
  const line = createOverlayContentLine(width, layout, borderFg, bodyBg);
557
- fillRange(line, innerStart, innerWidth, bodyBg);
531
+ fillWidth(line, innerStart, innerWidth, bodyBg);
558
532
 
559
533
  if (row === 0) {
560
534
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(currentStep.title, innerWidth - 2), {
@@ -573,20 +547,20 @@ function renderCollapsedLayout(
573
547
  bg: bodyBg,
574
548
  });
575
549
  } else if (row === 3) {
576
- fillRange(line, innerStart, innerWidth, UI_TONES.bg.section);
550
+ fillWidth(line, innerStart, innerWidth, UI_TONES.bg.section);
577
551
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(controlsText(wizard), innerWidth - 2), {
578
552
  fg: UI_TONES.state.info,
579
553
  bg: UI_TONES.bg.section,
580
554
  });
581
555
  } else if (row === 4) {
582
- fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
556
+ fillWidth(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
583
557
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, innerWidth - 2), {
584
558
  fg: UI_TONES.fg.primary,
585
559
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
586
560
  bold: true,
587
561
  });
588
562
  } else if (row === 5) {
589
- fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
563
+ fillWidth(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
590
564
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(selectedText.hint, innerWidth - 2), {
591
565
  fg: UI_TONES.fg.secondary,
592
566
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,