Skip to content

Commit af23bce

Browse files
committed
feat(tui): show one blinking MCP loading line with pending server names
1 parent 028a3f5 commit af23bce

4 files changed

Lines changed: 131 additions & 78 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
The TUI now shows one "Loading MCP: …" line with a blinking dot and the names of the servers still connecting, and removes it once all have loaded, instead of one line per connected server; failures and servers that need OAuth still get their own line.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Text } from '@pymodel/pi-tui';
2+
import type { TUI } from '@pymodel/pi-tui';
3+
4+
import { STATUS_BULLET } from '#/tui/constant/symbols';
5+
import { currentTheme } from '#/tui/theme';
6+
7+
const BLINK_INTERVAL_MS = 500;
8+
9+
/** Transient "Loading MCP: a, b" row with the same blinking bullet as running tools. */
10+
export class McpLoadingLine extends Text {
11+
private blinkOn = true;
12+
private names: readonly string[];
13+
private readonly timer: ReturnType<typeof setInterval>;
14+
15+
constructor(
16+
private readonly ui: TUI,
17+
names: readonly string[],
18+
) {
19+
super('', 1, 0);
20+
this.names = names;
21+
this.refresh();
22+
this.timer = setInterval(() => {
23+
this.blinkOn = !this.blinkOn;
24+
this.refresh();
25+
}, BLINK_INTERVAL_MS);
26+
}
27+
28+
setNames(names: readonly string[]): void {
29+
this.names = names;
30+
this.refresh();
31+
}
32+
33+
stop(): void {
34+
clearInterval(this.timer);
35+
}
36+
37+
private refresh(): void {
38+
const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' ';
39+
const label = currentTheme.fg('textMuted', `Loading MCP: ${this.names.join(', ')}`);
40+
this.setText(`${bullet}${label}`);
41+
this.ui.requestRender();
42+
}
43+
}

‎apps/pythinker-code/src/tui/controllers/session-event-handler.ts‎

Lines changed: 41 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import type {
3535

3636
import { ActivitySpinner } from '../components/chrome/activity-spinner';
3737
import { buildGoalMarker } from '../components/messages/goal-markers';
38-
import { StatusMessageComponent } from '../components/messages/status-message';
3938
import {
4039
DynamicWorkflowModeMarkerComponent,
4140
type DynamicWorkflowModeMarkerState,
@@ -69,7 +68,7 @@ import {
6968
selectMcpStartupStatusRows,
7069
} from '../utils/mcp-server-status';
7170
import { openUrl } from '#/utils/open-url';
72-
import { currentTheme } from '#/tui/theme';
71+
import { McpLoadingLine } from '../components/messages/mcp-loading-line';
7372
import type { ColorToken } from '#/tui/theme';
7473
import { errorReportHintLine } from '../constant/feedback';
7574
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
@@ -165,7 +164,7 @@ export class SessionEventHandler {
165164
renderedSkillActivationIds: Set<string> = new Set();
166165
renderedPluginCommandActivationIds: Set<string> = new Set();
167166
renderedMcpServerStatusKeys: Map<string, string> = new Map();
168-
mcpServerStatusSpinners: Map<string, ActivitySpinner> = new Map();
167+
mcpLoadingLine: McpLoadingLine | undefined;
169168
mcpServers: Map<string, McpServerStatusSnapshot> = new Map();
170169
private goalCompletionAwaitingClear = false;
171170
private goalCompletionTurnEnded = false;
@@ -252,9 +251,10 @@ export class SessionEventHandler {
252251
this.renderMcpServerStatus(server);
253252
}
254253

254+
const live = new Map(this.mcpServers);
255255
this.mcpServers.clear();
256256
for (const server of servers) {
257-
this.mcpServers.set(server.name, server);
257+
this.mcpServers.set(server.name, live.get(server.name) ?? server);
258258
}
259259
const hidden: McpServerStatusSnapshot[] = [];
260260
for (const server of servers) {
@@ -263,8 +263,9 @@ export class SessionEventHandler {
263263
this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server));
264264
hidden.push(server);
265265
}
266-
const summary = formatMcpStartupStatusSummary(servers);
266+
const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]);
267267
host.setAppState({ mcpServersSummary: summary || null });
268+
this.updateMcpLoadingRow();
268269
}
269270

270271
handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void {
@@ -321,10 +322,7 @@ export class SessionEventHandler {
321322
}
322323

323324
stopAllMcpServerStatusSpinners(): void {
324-
for (const spinner of this.mcpServerStatusSpinners.values()) {
325-
spinner.stop();
326-
}
327-
this.mcpServerStatusSpinners.clear();
325+
this.removeMcpLoadingRow();
328326
}
329327

330328
// ---------------------------------------------------------------------------
@@ -1018,77 +1016,52 @@ export class SessionEventHandler {
10181016
this.host.setAppState({ mcpServersSummary: summary || null });
10191017

10201018
switch (server.status) {
1021-
case 'connected': {
1022-
const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`;
1023-
const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`;
1024-
this.finalizeMcpServerStatusRow(server.name, message, 'success');
1025-
return;
1026-
}
1027-
case 'failed': {
1028-
const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`;
1029-
this.finalizeMcpServerStatusRow(server.name, message, 'error');
1030-
return;
1031-
}
1032-
case 'needs-auth': {
1033-
const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`;
1034-
this.finalizeMcpServerStatusRow(server.name, message, 'warning');
1035-
return;
1036-
}
1037-
case 'disabled':
1038-
this.finalizeMcpServerStatusRow(
1039-
server.name,
1040-
`MCP server "${server.name}" disabled`,
1041-
'textMuted',
1019+
case 'failed':
1020+
this.host.showStatus(
1021+
`MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`,
1022+
'error',
10421023
);
1043-
return;
1044-
case 'removed':
1045-
this.finalizeMcpServerStatusRow(
1046-
server.name,
1047-
`MCP server "${server.name}" removed`,
1048-
'textMuted',
1024+
break;
1025+
case 'needs-auth':
1026+
this.host.showStatus(
1027+
`MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`,
1028+
'warning',
10491029
);
1050-
return;
1051-
case 'pending':
1052-
this.showMcpServerStatusSpinner(server.name);
1053-
return;
1030+
break;
1031+
default:
1032+
break;
10541033
}
1034+
this.updateMcpLoadingRow();
10551035
}
10561036

1057-
private showMcpServerStatusSpinner(name: string): void {
1037+
// One transient row while servers connect; it leaves the transcript once none
1038+
// is pending. Connected servers are summarised in the welcome card instead.
1039+
private updateMcpLoadingRow(): void {
10581040
const { state } = this.host;
1059-
const label = `MCP server "${name}" connecting…`;
1060-
const existing = this.mcpServerStatusSpinners.get(name);
1061-
if (existing !== undefined) {
1062-
existing.setLabel(label);
1041+
const pending = [...this.mcpServers.values()]
1042+
.filter((server) => server.status === 'pending')
1043+
.map((server) => server.name);
1044+
if (pending.length === 0) {
1045+
this.removeMcpLoadingRow();
1046+
return;
1047+
}
1048+
if (this.mcpLoadingLine !== undefined) {
1049+
this.mcpLoadingLine.setNames(pending);
10631050
return;
10641051
}
1065-
const tint = (s: string): string => currentTheme.fg('textMuted', s);
1066-
const spinner = new ActivitySpinner(state.ui, tint, label);
1067-
state.transcriptContainer.addChild(spinner);
1068-
this.mcpServerStatusSpinners.set(name, spinner);
1052+
this.mcpLoadingLine = new McpLoadingLine(state.ui, pending);
1053+
state.transcriptContainer.addChild(this.mcpLoadingLine);
10691054
state.ui.requestRender();
10701055
}
10711056

1072-
private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void {
1073-
const { state } = this.host;
1074-
const spinner = this.mcpServerStatusSpinners.get(name);
1075-
if (spinner === undefined) {
1076-
this.host.showStatus(message, color);
1077-
return;
1078-
}
1057+
private removeMcpLoadingRow(): void {
1058+
const spinner = this.mcpLoadingLine;
1059+
if (spinner === undefined) return;
10791060
spinner.stop();
1080-
const status = new StatusMessageComponent(message, color);
1081-
const children = state.transcriptContainer.children;
1082-
const idx = children.indexOf(spinner);
1083-
if (idx >= 0) {
1084-
// In-place replacement is picked up by the container's ref-checked
1085-
// render cache; a tree-wide invalidate is unnecessary (and costly).
1086-
children[idx] = status;
1087-
} else {
1088-
state.transcriptContainer.addChild(status);
1089-
}
1090-
this.mcpServerStatusSpinners.delete(name);
1091-
state.ui.requestRender();
1061+
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
1062+
this.host.state.transcriptContainer.removeChild(spinner);
1063+
this.mcpLoadingLine = undefined;
1064+
this.host.state.ui.requestRender();
10921065
}
10931066

10941067
private handleSkillActivated(event: SkillActivatedEvent): void {

‎apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2786,12 +2786,12 @@ command = "vim"
27862786
}
27872787
expect(subscribeOrder).toBeLessThan(snapshotOrder);
27882788
const transcript = renderTranscript(driver);
2789-
expect(transcript).toContain('MCP server "local-tools" connected');
2790-
expect(transcript).toContain('2 tools (stdio)');
2789+
expect(transcript).not.toContain('MCP server "local-tools"');
2790+
expect(transcript).not.toContain('Loading MCP');
27912791
expect(transcript).toContain('MCP server "remote-tools" failed: connection refused');
27922792
});
27932793

2794-
it('deduplicates identical MCP status updates while allowing reconnect transitions', async () => {
2794+
it('shows one transient MCP loading line that disappears once servers connect', async () => {
27952795
const eventListeners: Array<(event: Event) => void> = [];
27962796
const connectedServer = {
27972797
name: 'local-tools',
@@ -2817,9 +2817,7 @@ command = "vim"
28172817
server: connectedServer,
28182818
} as Event);
28192819

2820-
expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe(
2821-
1,
2822-
);
2820+
expect(renderTranscript(driver)).not.toContain('MCP server "local-tools"');
28232821

28242822
eventListeners[0]?.({
28252823
type: 'mcp.server.status',
@@ -2831,16 +2829,50 @@ command = "vim"
28312829
toolCount: 0,
28322830
},
28332831
} as Event);
2832+
expect(countOccurrences(renderTranscript(driver), 'Loading MCP: local-tools')).toBe(1);
2833+
28342834
eventListeners[0]?.({
28352835
type: 'mcp.server.status',
28362836
agentId: 'main',
28372837
sessionId: 'ses-1',
28382838
server: connectedServer,
28392839
} as Event);
28402840

2841-
expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe(
2842-
2,
2843-
);
2841+
const transcript = renderTranscript(driver);
2842+
expect(transcript).not.toContain('Loading MCP');
2843+
expect(transcript).not.toContain('MCP server "local-tools"');
2844+
});
2845+
2846+
it('lists loading MCP server names and drops each one as it connects', async () => {
2847+
const eventListeners: Array<(event: Event) => void> = [];
2848+
const server = (name: string, status: 'pending' | 'connected') => ({
2849+
type: 'mcp.server.status',
2850+
agentId: 'main',
2851+
sessionId: 'ses-1',
2852+
server: { name, transport: 'http', status, toolCount: status === 'connected' ? 1 : 0 },
2853+
}) as Event;
2854+
const session = makeSession({
2855+
onEvent: vi.fn((listener: (event: Event) => void) => {
2856+
eventListeners.push(listener);
2857+
return vi.fn();
2858+
}),
2859+
listMcpServers: vi.fn(async () => []),
2860+
});
2861+
const { driver } = await makeDriver(session);
2862+
driver.sessionEventHandler.startSubscription();
2863+
await Promise.resolve();
2864+
2865+
eventListeners[0]?.(server('alpha', 'pending'));
2866+
eventListeners[0]?.(server('beta', 'pending'));
2867+
expect(renderTranscript(driver)).toContain('Loading MCP: alpha, beta');
2868+
2869+
eventListeners[0]?.(server('alpha', 'connected'));
2870+
const partial = renderTranscript(driver);
2871+
expect(partial).toContain('Loading MCP: beta');
2872+
expect(partial).not.toContain('alpha');
2873+
2874+
eventListeners[0]?.(server('beta', 'connected'));
2875+
expect(renderTranscript(driver)).not.toContain('Loading MCP');
28442876
});
28452877

28462878
it('does not let a late MCP snapshot overwrite a live status event', async () => {
@@ -2890,8 +2922,8 @@ command = "vim"
28902922
await Promise.resolve();
28912923

28922924
const transcript = renderTranscript(driver);
2893-
expect(transcript).toContain('MCP server "local-tools" connected');
28942925
expect(transcript).not.toContain('stale failure');
2926+
expect(driver.sessionEventHandler.mcpServers.get('local-tools')?.status).toBe('connected');
28952927
});
28962928

28972929
it('sends normal editor input to the active session and marks the turn as waiting', async () => {

0 commit comments

Comments
 (0)