@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.2

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.
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
7
  import { sanitizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
8
+ import { untilAborted } from "@oh-my-pi/pi-utils";
8
9
  import type { TSchema } from "@sinclair/typebox";
9
10
  import type { SourceMeta } from "../capability/types";
10
11
  import type {
@@ -17,22 +18,43 @@ import type { Theme } from "../modes/theme/theme";
17
18
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
18
19
  import { callTool } from "./client";
19
20
  import { renderMCPCall, renderMCPResult } from "./render";
20
- import type { MCPContent, MCPServerConnection, MCPToolDefinition } from "./types";
21
+ import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types";
21
22
 
22
- function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
23
- if (!signal) return promise;
24
- if (signal.aborted) {
25
- return Promise.reject(signal.reason instanceof Error ? signal.reason : new ToolAbortError());
26
- }
23
+ /** Reconnect callback: tears down stale connection, returns new one or null. */
24
+ export type MCPReconnect = () => Promise<MCPServerConnection | null>;
27
25
 
28
- const { promise: wrapped, resolve, reject } = Promise.withResolvers<T>();
29
- const onAbort = () => {
30
- reject(signal.reason instanceof Error ? signal.reason : new ToolAbortError());
31
- };
26
+ /**
27
+ * Network-level and stale-session errors that warrant a reconnect + single retry.
28
+ * Conservative: only catches errors where the server is likely alive but the
29
+ * connection object is stale (dead SSE, expired session, refused after restart).
30
+ */
31
+ const RETRIABLE_PATTERNS = [
32
+ "econnrefused",
33
+ "econnreset",
34
+ "epipe",
35
+ "enetunreach",
36
+ "ehostunreach",
37
+ "fetch failed",
38
+ "transport not connected",
39
+ "transport closed",
40
+ "network error",
41
+ ];
42
+
43
+ export function isRetriableConnectionError(error: unknown): boolean {
44
+ if (!(error instanceof Error)) return false;
45
+ const msg = error.message.toLowerCase();
46
+ // Stale session (server restarted, old session ID is gone)
47
+ if (/^http (404|502|503):/.test(msg)) return true;
48
+ return RETRIABLE_PATTERNS.some(p => msg.includes(p));
49
+ }
32
50
 
33
- signal.addEventListener("abort", onAbort, { once: true });
34
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
35
- return wrapped;
51
+ type MCPToolArgs = NonNullable<MCPToolCallParams["arguments"]>;
52
+
53
+ function normalizeToolArgs(value: unknown): MCPToolArgs {
54
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
55
+ return {};
56
+ }
57
+ return value as MCPToolArgs;
36
58
  }
37
59
 
38
60
  /** Details included in MCP tool results for rendering */
@@ -77,6 +99,60 @@ function formatMCPContent(content: MCPContent[]): string {
77
99
  return parts.join("\n\n");
78
100
  }
79
101
 
102
+ /** Build a CustomToolResult from a callTool response. */
103
+ function buildResult(
104
+ result: MCPToolCallResult,
105
+ serverName: string,
106
+ mcpToolName: string,
107
+ provider?: string,
108
+ providerName?: string,
109
+ ): CustomToolResult<MCPToolDetails> {
110
+ const text = formatMCPContent(result.content);
111
+ const details: MCPToolDetails = {
112
+ serverName,
113
+ mcpToolName,
114
+ isError: result.isError,
115
+ rawContent: result.content,
116
+ provider,
117
+ providerName,
118
+ };
119
+ if (result.isError) {
120
+ return { content: [{ type: "text", text: `Error: ${text}` }], details };
121
+ }
122
+ return { content: [{ type: "text", text }], details };
123
+ }
124
+
125
+ /** Build an error CustomToolResult from a caught exception. */
126
+ function buildErrorResult(
127
+ error: unknown,
128
+ serverName: string,
129
+ mcpToolName: string,
130
+ provider?: string,
131
+ providerName?: string,
132
+ ): CustomToolResult<MCPToolDetails> {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ return {
135
+ content: [{ type: "text", text: `MCP error: ${message}` }],
136
+ details: { serverName, mcpToolName, isError: true, provider, providerName },
137
+ };
138
+ }
139
+
140
+ /** Re-throw abort-related errors so they bypass error-result handling. */
141
+ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
142
+ if (error instanceof ToolAbortError) throw error;
143
+ if (error instanceof Error && error.name === "AbortError") throw new ToolAbortError();
144
+ if (signal?.aborted) throw new ToolAbortError();
145
+ }
146
+
147
+ async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise<MCPServerConnection | null> {
148
+ try {
149
+ return await untilAborted(signal, reconnect);
150
+ } catch (error) {
151
+ rethrowIfAborted(error, signal);
152
+ return null;
153
+ }
154
+ }
155
+
80
156
  /**
81
157
  * Create a unique tool name for an MCP tool.
82
158
  *
@@ -143,13 +219,14 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
143
219
  readonly mcpServerName: string;
144
220
 
145
221
  /** Create MCPTool instances for all tools from an MCP server connection */
146
- static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[]): MCPTool[] {
147
- return tools.map(tool => new MCPTool(connection, tool));
222
+ static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
223
+ return tools.map(tool => new MCPTool(connection, tool, reconnect));
148
224
  }
149
225
 
150
226
  constructor(
151
- private readonly connection: MCPServerConnection,
227
+ private connection: MCPServerConnection,
152
228
  private readonly tool: MCPToolDefinition,
229
+ private readonly reconnect?: MCPReconnect,
153
230
  ) {
154
231
  this.name = createMCPToolName(connection.name, tool.name);
155
232
  this.label = `${connection.name}/${tool.name}`;
@@ -160,11 +237,11 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
160
237
  }
161
238
 
162
239
  renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
163
- return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
240
+ return renderMCPCall(normalizeToolArgs(args), theme, this.label);
164
241
  }
165
242
 
166
243
  renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
167
- return renderMCPResult(result, options, theme, (args ?? {}) as Record<string, unknown>);
244
+ return renderMCPResult(result, options, theme, normalizeToolArgs(args));
168
245
  }
169
246
 
170
247
  async execute(
@@ -175,51 +252,38 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
175
252
  signal?: AbortSignal,
176
253
  ): Promise<CustomToolResult<MCPToolDetails>> {
177
254
  throwIfAborted(signal);
178
- try {
179
- const result = await callTool(this.connection, this.tool.name, params as Record<string, unknown>, { signal });
180
-
181
- const text = formatMCPContent(result.content);
182
- const details: MCPToolDetails = {
183
- serverName: this.connection.name,
184
- mcpToolName: this.tool.name,
185
- isError: result.isError,
186
- rawContent: result.content,
187
- provider: this.connection._source?.provider,
188
- providerName: this.connection._source?.providerName,
189
- };
190
-
191
- if (result.isError) {
192
- return {
193
- content: [{ type: "text", text: `Error: ${text}` }],
194
- details,
195
- };
196
- }
255
+ const args = normalizeToolArgs(params);
256
+ const provider = this.connection._source?.provider;
257
+ const providerName = this.connection._source?.providerName;
197
258
 
198
- return {
199
- content: [{ type: "text", text }],
200
- details,
201
- };
259
+ try {
260
+ const result = await callTool(this.connection, this.tool.name, args, { signal });
261
+ return buildResult(result, this.connection.name, this.tool.name, provider, providerName);
202
262
  } catch (error) {
203
- if (error instanceof ToolAbortError) {
204
- throw error;
205
- }
206
- if (error instanceof Error && error.name === "AbortError") {
207
- throw new ToolAbortError();
208
- }
209
- if (signal?.aborted) {
210
- throw new ToolAbortError();
263
+ rethrowIfAborted(error, signal);
264
+ if (this.reconnect && isRetriableConnectionError(error)) {
265
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
266
+ if (newConn) {
267
+ // Rebind so subsequent calls on this instance use the fresh connection
268
+ this.connection = newConn;
269
+ const retryProvider = newConn._source?.provider ?? provider;
270
+ const retryProviderName = newConn._source?.providerName ?? providerName;
271
+ try {
272
+ const result = await callTool(newConn, this.tool.name, args, { signal });
273
+ return buildResult(result, newConn.name, this.tool.name, retryProvider, retryProviderName);
274
+ } catch (retryError) {
275
+ rethrowIfAborted(retryError, signal);
276
+ return buildErrorResult(
277
+ retryError,
278
+ this.connection.name,
279
+ this.tool.name,
280
+ retryProvider,
281
+ retryProviderName,
282
+ );
283
+ }
284
+ }
211
285
  }
212
- const message = error instanceof Error ? error.message : String(error);
213
- return {
214
- content: [{ type: "text", text: `MCP error: ${message}` }],
215
- details: {
216
- serverName: this.connection.name,
217
- mcpToolName: this.tool.name,
218
- isError: true,
219
- provider: this.connection._source?.provider,
220
- providerName: this.connection._source?.providerName,
221
- },
222
- };
286
+ return buildErrorResult(error, this.connection.name, this.tool.name, provider, providerName);
223
287
  }
224
288
  }
225
289
  }
@@ -245,8 +309,9 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
245
309
  tools: MCPToolDefinition[],
246
310
  getConnection: () => Promise<MCPServerConnection>,
247
311
  source?: SourceMeta,
312
+ reconnect?: MCPReconnect,
248
313
  ): DeferredMCPTool[] {
249
- return tools.map(tool => new DeferredMCPTool(serverName, tool, getConnection, source));
314
+ return tools.map(tool => new DeferredMCPTool(serverName, tool, getConnection, source, reconnect));
250
315
  }
251
316
 
252
317
  constructor(
@@ -254,6 +319,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
254
319
  private readonly tool: MCPToolDefinition,
255
320
  private readonly getConnection: () => Promise<MCPServerConnection>,
256
321
  source?: SourceMeta,
322
+ private readonly reconnect?: MCPReconnect,
257
323
  ) {
258
324
  this.name = createMCPToolName(serverName, tool.name);
259
325
  this.label = `${serverName}/${tool.name}`;
@@ -266,11 +332,11 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
266
332
  }
267
333
 
268
334
  renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
269
- return renderMCPCall((args ?? {}) as Record<string, unknown>, theme, this.label);
335
+ return renderMCPCall(normalizeToolArgs(args), theme, this.label);
270
336
  }
271
337
 
272
338
  renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
273
- return renderMCPResult(result, options, theme, (args ?? {}) as Record<string, unknown>);
339
+ return renderMCPResult(result, options, theme, normalizeToolArgs(args));
274
340
  }
275
341
 
276
342
  async execute(
@@ -281,53 +347,70 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
281
347
  signal?: AbortSignal,
282
348
  ): Promise<CustomToolResult<MCPToolDetails>> {
283
349
  throwIfAborted(signal);
350
+ const args = normalizeToolArgs(params);
351
+ const provider = this.#fallbackProvider;
352
+ const providerName = this.#fallbackProviderName;
353
+
284
354
  try {
285
- const connection = await withAbort(this.getConnection(), signal);
355
+ const connection = await untilAborted(signal, () => this.getConnection());
286
356
  throwIfAborted(signal);
287
- const result = await callTool(connection, this.tool.name, params as Record<string, unknown>, { signal });
288
-
289
- const text = formatMCPContent(result.content);
290
- const details: MCPToolDetails = {
291
- serverName: this.serverName,
292
- mcpToolName: this.tool.name,
293
- isError: result.isError,
294
- rawContent: result.content,
295
- provider: connection._source?.provider ?? this.#fallbackProvider,
296
- providerName: connection._source?.providerName ?? this.#fallbackProviderName,
297
- };
298
-
299
- if (result.isError) {
300
- return {
301
- content: [{ type: "text", text: `Error: ${text}` }],
302
- details,
303
- };
304
- }
305
-
306
- return {
307
- content: [{ type: "text", text }],
308
- details,
309
- };
310
- } catch (error) {
311
- if (error instanceof ToolAbortError) {
312
- throw error;
313
- }
314
- if (error instanceof Error && error.name === "AbortError") {
315
- throw new ToolAbortError();
357
+ try {
358
+ const result = await callTool(connection, this.tool.name, args, { signal });
359
+ return buildResult(
360
+ result,
361
+ this.serverName,
362
+ this.tool.name,
363
+ connection._source?.provider ?? provider,
364
+ connection._source?.providerName ?? providerName,
365
+ );
366
+ } catch (callError) {
367
+ rethrowIfAborted(callError, signal);
368
+ if (this.reconnect && isRetriableConnectionError(callError)) {
369
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
370
+ if (newConn) {
371
+ const retryProvider = newConn._source?.provider ?? provider;
372
+ const retryProviderName = newConn._source?.providerName ?? providerName;
373
+ try {
374
+ const result = await callTool(newConn, this.tool.name, args, { signal });
375
+ return buildResult(result, this.serverName, this.tool.name, retryProvider, retryProviderName);
376
+ } catch (retryError) {
377
+ rethrowIfAborted(retryError, signal);
378
+ return buildErrorResult(
379
+ retryError,
380
+ this.serverName,
381
+ this.tool.name,
382
+ retryProvider,
383
+ retryProviderName,
384
+ );
385
+ }
386
+ }
387
+ }
388
+ return buildErrorResult(callError, this.serverName, this.tool.name, provider, providerName);
316
389
  }
317
- if (signal?.aborted) {
318
- throw new ToolAbortError();
390
+ } catch (connError) {
391
+ // getConnection() failed — server never connected or connection lost.
392
+ // This is always worth a reconnect attempt for deferred tools, since the
393
+ // error ("MCP server not connected") isn't a network error from callTool.
394
+ rethrowIfAborted(connError, signal);
395
+ if (this.reconnect) {
396
+ const newConn = await reconnectWithAbort(this.reconnect, signal);
397
+ if (newConn) {
398
+ try {
399
+ const result = await callTool(newConn, this.tool.name, args, { signal });
400
+ return buildResult(
401
+ result,
402
+ this.serverName,
403
+ this.tool.name,
404
+ newConn._source?.provider ?? provider,
405
+ newConn._source?.providerName ?? providerName,
406
+ );
407
+ } catch (retryError) {
408
+ rethrowIfAborted(retryError, signal);
409
+ return buildErrorResult(retryError, this.serverName, this.tool.name, provider, providerName);
410
+ }
411
+ }
319
412
  }
320
- const message = error instanceof Error ? error.message : String(error);
321
- return {
322
- content: [{ type: "text", text: `MCP error: ${message}` }],
323
- details: {
324
- serverName: this.serverName,
325
- mcpToolName: this.tool.name,
326
- isError: true,
327
- provider: this.#fallbackProvider,
328
- providerName: this.#fallbackProviderName,
329
- },
330
- };
413
+ return buildErrorResult(connError, this.serverName, this.tool.name, provider, providerName);
331
414
  }
332
415
  }
333
416
  }
@@ -4,7 +4,7 @@
4
4
  * Implements JSON-RPC 2.0 over HTTP POST with optional SSE streaming.
5
5
  * Based on MCP spec 2025-03-26.
6
6
  */
7
- import { readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
7
+ import { logger, readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
8
8
  import type {
9
9
  JsonRpcError,
10
10
  JsonRpcMessage,
@@ -91,13 +91,16 @@ export class HttpTransport implements MCPTransport {
91
91
  return;
92
92
  }
93
93
 
94
- // Connection established — read messages in background, reset sseConnection when done
94
+ // Connection established — read messages in background.
95
+ // If the stream ends unexpectedly (server restart, network drop),
96
+ // fire onClose so the manager can trigger reconnection.
95
97
  const signal = this.#sseConnection.signal;
96
98
  void this.#readSSEStream(response.body!, signal).finally(() => {
99
+ const wasConnected = this.#connected;
97
100
  this.#sseConnection = null;
101
+ if (wasConnected) this.onClose?.();
98
102
  });
99
103
  }
100
-
101
104
  async #readSSEStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
102
105
  try {
103
106
  for await (const message of readSseJson<JsonRpcMessage>(body, signal)) {
@@ -106,6 +109,7 @@ export class HttpTransport implements MCPTransport {
106
109
  }
107
110
  } catch (error) {
108
111
  if (error instanceof Error && error.name !== "AbortError") {
112
+ logger.debug("HTTP SSE stream error", { url: this.config.url, error: error.message });
109
113
  this.onError?.(error);
110
114
  }
111
115
  }
@@ -457,6 +461,7 @@ export class HttpTransport implements MCPTransport {
457
461
  }
458
462
 
459
463
  this.onClose?.();
464
+ this.onClose = undefined;
460
465
  }
461
466
  }
462
467
 
@@ -6,13 +6,17 @@ import { sanitizeText } from "@oh-my-pi/pi-natives";
6
6
  import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
7
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
- import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
9
+ import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
10
10
  import { DynamicBorder } from "./dynamic-border";
11
11
  import { truncateToVisualLines } from "./visual-truncate";
12
12
 
13
13
  // Preview line limit when not expanded (matches tool execution behavior)
14
14
  const PREVIEW_LINES = 20;
15
+ const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
15
16
  const MAX_DISPLAY_LINE_CHARS = 4000;
17
+ // Minimum interval between processing incoming chunks for display (ms).
18
+ // Chunks arriving faster than this are accumulated and processed in one batch.
19
+ const CHUNK_THROTTLE_MS = 50;
16
20
 
17
21
  export class BashExecutionComponent extends Container {
18
22
  #outputLines: string[] = [];
@@ -21,7 +25,10 @@ export class BashExecutionComponent extends Container {
21
25
  #loader: Loader;
22
26
  #truncation?: TruncationMeta;
23
27
  #expanded = false;
28
+ #displayDirty = false;
29
+ #chunkGate = false;
24
30
  #contentContainer: Container;
31
+ #headerText: Text;
25
32
 
26
33
  constructor(
27
34
  private readonly command: string,
@@ -45,8 +52,8 @@ export class BashExecutionComponent extends Container {
45
52
  this.addChild(this.#contentContainer);
46
53
 
47
54
  // Command header
48
- const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
49
- this.#contentContainer.addChild(header);
55
+ this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
56
+ this.#contentContainer.addChild(this.#headerText);
50
57
 
51
58
  // Loader
52
59
  this.#loader = new Loader(
@@ -72,14 +79,22 @@ export class BashExecutionComponent extends Container {
72
79
 
73
80
  override invalidate(): void {
74
81
  super.invalidate();
82
+ this.#displayDirty = false;
75
83
  this.#updateDisplay();
76
84
  }
77
85
 
78
86
  appendOutput(chunk: string): void {
79
- const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
80
-
81
- // Append to output lines
82
- const incomingLines = clean.split("\n");
87
+ // During high-throughput output (e.g. seq 1 500M), processing every
88
+ // chunk would saturate the event loop. Instead, accept one chunk per
89
+ // throttle window and drop the rest — the OutputSink captures everything
90
+ // for the artifact, and setComplete() replaces with the final output.
91
+ if (this.#chunkGate) return;
92
+ this.#chunkGate = true;
93
+ setTimeout(() => {
94
+ this.#chunkGate = false;
95
+ }, CHUNK_THROTTLE_MS);
96
+
97
+ const incomingLines = chunk.split("\n");
83
98
  if (this.#outputLines.length > 0 && incomingLines.length > 0) {
84
99
  const lastIndex = this.#outputLines.length - 1;
85
100
  const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
@@ -90,7 +105,12 @@ export class BashExecutionComponent extends Container {
90
105
  this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
91
106
  }
92
107
 
93
- this.#updateDisplay();
108
+ // Cap stored lines during streaming to avoid unbounded memory growth
109
+ if (this.#outputLines.length > STREAMING_LINE_CAP) {
110
+ this.#outputLines = this.#outputLines.slice(-STREAMING_LINE_CAP);
111
+ }
112
+
113
+ this.#displayDirty = true;
94
114
  }
95
115
 
96
116
  setComplete(
@@ -115,6 +135,14 @@ export class BashExecutionComponent extends Container {
115
135
  this.#updateDisplay();
116
136
  }
117
137
 
138
+ override render(width: number): string[] {
139
+ if (this.#displayDirty) {
140
+ this.#displayDirty = false;
141
+ this.#updateDisplay();
142
+ }
143
+ return super.render(width);
144
+ }
145
+
118
146
  #updateDisplay(): void {
119
147
  const availableLines = this.#outputLines;
120
148
 
@@ -122,15 +150,16 @@ export class BashExecutionComponent extends Container {
122
150
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
123
151
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
124
152
  const sixelLineMask =
125
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(availableLines) : undefined;
153
+ TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
154
+ ? getSixelLineMask(availableLines)
155
+ : undefined;
126
156
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
127
157
 
128
158
  // Rebuild content container
129
159
  this.#contentContainer.clear();
130
160
 
131
161
  // Command header
132
- const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
133
- this.#contentContainer.addChild(header);
162
+ this.#contentContainer.addChild(this.#headerText);
134
163
 
135
164
  // Output
136
165
  if (availableLines.length > 0) {
@@ -72,9 +72,8 @@ export class PythonExecutionComponent extends Container {
72
72
  }
73
73
 
74
74
  appendOutput(chunk: string): void {
75
- const clean = sanitizeText(chunk);
76
-
77
- const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
75
+ // Chunk is pre-sanitized by OutputSink.push() — no need to sanitize again.
76
+ const newLines = chunk.split("\n").map(line => this.#clampDisplayLine(line));
78
77
  if (this.#outputLines.length > 0 && newLines.length > 0) {
79
78
  this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
80
79
  `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
@@ -105,17 +105,16 @@ export class ToolExecutionComponent extends Container {
105
105
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
106
106
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
107
107
  // Spinner animation for partial task results
108
- #spinnerFrame = 0;
108
+ #spinnerFrame?: number;
109
109
  #spinnerInterval?: NodeJS.Timeout;
110
110
  // Track if args are still being streamed (for edit/write spinner)
111
111
  #argsComplete = false;
112
112
  #renderState: {
113
- spinnerFrame: number;
113
+ spinnerFrame?: number;
114
114
  expanded: boolean;
115
115
  isPartial: boolean;
116
116
  renderContext?: Record<string, unknown>;
117
117
  } = {
118
- spinnerFrame: 0,
119
118
  expanded: false,
120
119
  isPartial: true,
121
120
  };
@@ -328,10 +327,9 @@ export class ToolExecutionComponent extends Container {
328
327
  this.#spinnerInterval = setInterval(() => {
329
328
  const frameCount = theme.spinnerFrames.length;
330
329
  if (frameCount === 0) return;
331
- this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
330
+ this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
332
331
  this.#renderState.spinnerFrame = this.#spinnerFrame;
333
332
  this.#ui.requestRender();
334
- // NO updateDisplay() — existing component closures read from renderState
335
333
  }, 80);
336
334
  } else if (!needsSpinner && this.#spinnerInterval) {
337
335
  clearInterval(this.#spinnerInterval);
@@ -346,6 +344,7 @@ export class ToolExecutionComponent extends Container {
346
344
  if (this.#spinnerInterval) {
347
345
  clearInterval(this.#spinnerInterval);
348
346
  this.#spinnerInterval = undefined;
347
+ this.#spinnerFrame = undefined;
349
348
  }
350
349
  }
351
350
 
@@ -690,7 +690,6 @@ export class CommandController {
690
690
  chunk => {
691
691
  if (this.ctx.bashComponent) {
692
692
  this.ctx.bashComponent.appendOutput(chunk);
693
- this.ctx.ui.requestRender();
694
693
  }
695
694
  },
696
695
  { excludeFromContext },
@@ -732,7 +731,6 @@ export class CommandController {
732
731
  chunk => {
733
732
  if (this.ctx.pythonComponent) {
734
733
  this.ctx.pythonComponent.appendOutput(chunk);
735
- this.ctx.ui.requestRender();
736
734
  }
737
735
  },
738
736
  { excludeFromContext },
@@ -127,6 +127,9 @@ export class MCPCommandController {
127
127
  case "smithery-logout":
128
128
  await this.#handleSmitheryLogout();
129
129
  break;
130
+ case "reconnect":
131
+ await this.#handleReconnect(parts[2]);
132
+ break;
130
133
  case "reload":
131
134
  await this.#handleReload();
132
135
  break;
@@ -159,6 +162,7 @@ export class MCPCommandController {
159
162
  " Search Smithery registry and deploy from picker",
160
163
  " /mcp smithery-login Login to Smithery and cache API key",
161
164
  " /mcp smithery-logout Remove cached Smithery API key",
165
+ " /mcp reconnect <name> Reconnect to a specific MCP server",
162
166
  " /mcp reload Force reload and rediscover MCP runtime tools",
163
167
  " /mcp resources List available resources from connected servers",
164
168
  " /mcp prompts List available prompts from connected servers",
@@ -1372,6 +1376,47 @@ export class MCPCommandController {
1372
1376
  }
1373
1377
  }
1374
1378
 
1379
+ /**
1380
+ * Handle /mcp reconnect <name> - Reconnect to a specific server.
1381
+ */
1382
+ async #handleReconnect(name: string | undefined): Promise<void> {
1383
+ if (!name) {
1384
+ this.ctx.showError("Server name required. Usage: /mcp reconnect <name>");
1385
+ return;
1386
+ }
1387
+ if (!this.ctx.mcpManager) {
1388
+ this.ctx.showError("MCP manager not available.");
1389
+ return;
1390
+ }
1391
+
1392
+ this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
1393
+
1394
+ try {
1395
+ const connection = await this.ctx.mcpManager.reconnectServer(name);
1396
+ if (connection) {
1397
+ // refreshMCPTools re-registers tools and preserves the user's prior
1398
+ // MCP tool selection. No need to call activateDiscoveredMCPTools —
1399
+ // that would broaden the selection to all server tools.
1400
+ await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1401
+ const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name);
1402
+ this.#showMessage(
1403
+ [
1404
+ "\n",
1405
+ theme.fg("success", `\u2713 Reconnected to "${name}"`),
1406
+ ` Tools: ${serverTools.length}`,
1407
+ "\n",
1408
+ ].join("\n"),
1409
+ );
1410
+ } else {
1411
+ this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`);
1412
+ }
1413
+ } catch (error) {
1414
+ this.ctx.showError(
1415
+ `Failed to reconnect to "${name}": ${error instanceof Error ? error.message : String(error)}`,
1416
+ );
1417
+ }
1418
+ }
1419
+
1375
1420
  /**
1376
1421
  * Reload MCP manager with new configs
1377
1422
  */