Merge remote-tracking branch 'upstream/main' into feat/secret-friendly-names

This commit is contained in:
Mathews-Tom
2026-07-01 00:34:03 +05:30
228 changed files with 9670 additions and 1571 deletions
@@ -24,7 +24,9 @@ import {
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
import { Settings } from "../../../config/settings";
import { setMcpServerEnabled } from "../../../mcp/config-writer";
import { getTabBarTheme } from "../../../modes/shared";
import { theme } from "../../../modes/theme/theme";
import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
@@ -263,6 +265,14 @@ export class ExtensionDashboard implements Component {
const sm = this.settings ?? Settings.instance;
if (!sm) return;
// MCP toggles route through the canonical denylist in
// `~/.omp/agent/mcp.json` so `/mcp list`, the MCP runtime, and this
// dashboard agree on every server's enabled state (issue #3827).
if (extensionId.startsWith("mcp:")) {
void this.#toggleMcpExtension(extensionId, enabled, sm);
return;
}
const disabled = ((sm.get("disabledExtensions") as string[]) ?? []).slice();
if (enabled) {
const index = disabled.indexOf(extensionId);
@@ -281,6 +291,42 @@ export class ExtensionDashboard implements Component {
void this.#refreshFromState();
}
async #toggleMcpExtension(extensionId: string, enabled: boolean, sm: Settings): Promise<void> {
const name = extensionId.slice("mcp:".length);
try {
await setMcpServerEnabled({
userPath: getMCPConfigPath("user", this.cwd),
projectPath: getMCPConfigPath("project", this.cwd),
sourcePath: this.#writableMcpSourcePath(extensionId),
name,
enabled,
});
} catch (error) {
logger.warn("Failed to persist MCP toggle", { name, enabled, error: String(error) });
}
// Reconcile `settings.disabledExtensions` with the canonical mcp.json
// state so a legacy `mcp:<name>` flag from before this routing change
// doesn't keep the server marked disabled after the user re-enables it
// via the UI.
const stored = ((sm.get("disabledExtensions") as string[]) ?? []).slice();
const had = stored.indexOf(extensionId);
if (enabled && had !== -1) {
stored.splice(had, 1);
sm.set("disabledExtensions", stored);
this.#applyDisabledExtensions(stored);
}
await this.#refreshFromState();
}
#writableMcpSourcePath(extensionId: string): string | undefined {
const extension = this.#state.extensions.find(ext => ext.id === extensionId);
if (!extension) return undefined;
if (extension.source.provider !== "native" && extension.source.provider !== "mcp-json") return undefined;
return extension.path;
}
async #refreshFromState(): Promise<void> {
const refreshToken = ++this.#refreshToken;
// Remember the current tab so it survives the re-sort.
@@ -4,7 +4,7 @@
*/
import * as path from "node:path";
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
import type { ContextFile } from "../../../capability/context-file";
import type { ExtensionModule } from "../../../capability/extension-module";
import type { Hook } from "../../../capability/hook";
@@ -22,6 +22,7 @@ import {
isProviderEnabled,
loadCapability,
} from "../../../discovery";
import { readDisabledServers, readEnabledServers } from "../../../mcp/config-writer";
import type {
DashboardState,
Extension,
@@ -141,12 +142,32 @@ export async function loadAllExtensions(cwd?: string, disabledIds?: string[]): P
logger.warn("Failed to load extension-modules capability", { error: String(error) });
}
// Load MCP servers
// Load MCP servers. The dashboard mirrors `/mcp list` (issue #3827) by
// honoring the same disable signals: the dashboard-private settings list,
// the per-server `enabled: false` flag, and the user-level `disabledServers`
// denylist that `/mcp disable` writes through `setServerDisabled`. The
// user-level `enabledServers` allowlist overrides a non-writable source's
// `enabled: false` (e.g. opencode.json) but never the denylist.
try {
const userMcpPath = cwd ? getMCPConfigPath("user", cwd) : undefined;
const [mcpDisabledNames, mcpForcedEnabled] = await Promise.all([
userMcpPath
? readDisabledServers(userMcpPath)
.then(list => new Set(list))
.catch(() => new Set<string>())
: Promise.resolve(new Set<string>()),
userMcpPath
? readEnabledServers(userMcpPath)
.then(list => new Set(list))
.catch(() => new Set<string>())
: Promise.resolve(new Set<string>()),
]);
const mcps = await loadCapability<MCPServer>("mcps", loadOpts);
for (const server of mcps.all) {
const id = makeExtensionId("mcp", server.name);
const isDisabled = disabledExtensions.has(id);
const forced = mcpForcedEnabled.has(server.name);
const sourceSaysDisabled = server.enabled === false && !forced;
const isDisabled = mcpDisabledNames.has(server.name) || disabledExtensions.has(id) || sourceSaysDisabled;
const isShadowed = (server as { _shadowed?: boolean })._shadowed;
const providerEnabled = isProviderEnabled(server._source.provider);
@@ -63,6 +63,14 @@ export interface MCPAddWizardOAuthResult {
interface MCPAddWizardOAuthOptions {
serverUrl?: string;
resource?: string;
/**
* External cancellation source. Aborting it tears down the in-flight OAuth
* flow and surfaces a neutral cancellation error. The wizard wires its own
* controller here so Esc cancels the OAuth wait instead of stepping back
* through the form (the wizard is focused, so the editor's Esc hook does
* not fire).
*/
abortSignal?: AbortSignal;
}
interface WizardState {
@@ -135,6 +143,12 @@ export class MCPAddWizard extends Container {
| null = null;
#onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
#onRenderCallback: (() => void) | null = null;
/**
* Set while the OAuth callback is in flight; populated by
* {@link #launchOAuthFlow} and consumed by {@link handleInput} so Esc
* cancels the OAuth wait instead of stepping back through the form.
*/
#oauthAbort: AbortController | null = null;
constructor(
onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
@@ -473,6 +487,15 @@ export class MCPAddWizard extends Container {
}
handleInput(keyData: string): void {
// While an OAuth callback is being awaited, Esc/Ctrl+C aborts the flow
// rather than stepping back through the form: the wizard advertises
// "(Press Esc to cancel)" during the wait, and stepping back would
// leave the OAuth login orphaned.
if (this.#oauthAbort && (keyData === "\x03" || matchesAppInterrupt(keyData))) {
this.#oauthAbort.abort("MCP OAuth flow cancelled by user");
return;
}
// Handle Ctrl+C to cancel wizard immediately
if (keyData === "\x03") {
// Ctrl+C pressed - cancel wizard
@@ -1153,6 +1176,7 @@ export class MCPAddWizard extends Container {
this.#contentContainer.addChild(new Text(theme.fg("muted", "(Press Esc to cancel)"), 0, 0));
this.#requestRender();
this.#oauthAbort = new AbortController();
try {
// Call OAuth handler
const oauthResource = this.#state.oauthResource || (this.#state.transport === "stdio" ? "" : this.#state.url);
@@ -1165,6 +1189,7 @@ export class MCPAddWizard extends Container {
{
serverUrl: this.#state.url || undefined,
resource: oauthResource || undefined,
abortSignal: this.#oauthAbort.signal,
},
);
@@ -1237,16 +1262,29 @@ export class MCPAddWizard extends Container {
healthPassed ? 1000 : 2000,
);
} catch (error) {
// Show error with options to retry or go back
// User cancellation has its own neutral heading + tip; everything else
// keeps the "OAuth authentication failed" framing so the existing tips
// stay meaningful. Name-matching avoids importing controller types.
const cancelled = error instanceof Error && error.name === "MCPOAuthCancelledError";
const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
this.#contentContainer.clear();
this.#contentContainer.addChild(new Text(theme.fg("error", "✗ OAuth authentication failed"), 0, 0));
this.#contentContainer.addChild(
new Text(
cancelled ? theme.fg("muted", "○ OAuth cancelled") : theme.fg("error", "✗ OAuth authentication failed"),
0,
0,
),
);
this.#contentContainer.addChild(new Spacer(1));
this.#contentContainer.addChild(new Text(errorMsg, 0, 0));
this.#contentContainer.addChild(new Spacer(1));
// Provide helpful tips based on error type
if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
if (cancelled) {
this.#contentContainer.addChild(
new Text(theme.fg("muted", "Tip: Choose Retry to launch the browser again."), 0, 0),
);
} else if (errorMsg.includes("timeout") || errorMsg.includes("timed out")) {
this.#contentContainer.addChild(
new Text(theme.fg("muted", "Tip: Complete authorization faster next time"), 0, 0),
);
@@ -1272,6 +1310,8 @@ export class MCPAddWizard extends Container {
// Set up as a selector step
this.#selectedIndex = 0;
this.#currentStep = "oauth-error";
} finally {
this.#oauthAbort = null;
}
}
@@ -24,7 +24,12 @@ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../..
import type { Settings } from "../../config/settings";
import { type ThemeColor, theme } from "../../modes/theme/theme";
import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
getConfiguredThinkingLevelMetadata,
parseConfiguredThinkingLevel,
} from "../../thinking";
import { getTabBarTheme } from "../shared";
import { DynamicBorder } from "./dynamic-border";
@@ -342,10 +347,7 @@ export class ModelSelectorComponent extends Container {
if (resolved.model) {
nextRoles[role] = {
model: resolved.model,
thinkingLevel:
resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
? resolved.thinkingLevel
: ThinkingLevel.Inherit,
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
autoSelected: false,
};
}
@@ -363,10 +365,7 @@ export class ModelSelectorComponent extends Container {
if (!resolved.model) continue;
nextRoles[role] = {
model: resolved.model,
thinkingLevel:
resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined
? resolved.thinkingLevel
: ThinkingLevel.Inherit,
thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
autoSelected: true,
};
}
@@ -1059,6 +1058,19 @@ export class ModelSelectorComponent extends Container {
);
}
}
#getResolvedRoleThinkingLevel(
role: string,
resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ThinkingLevel },
): ConfiguredThinkingLevel {
if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
return resolved.thinkingLevel;
}
if (role === "default") {
return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
}
return ThinkingLevel.Inherit;
}
#getThinkingLevelsForModel(model: Model): ReadonlyArray<ConfiguredThinkingLevel> {
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
}
@@ -609,6 +609,15 @@ export class EventController {
}
for (const content of this.ctx.streamingMessage.content) {
if (content.type !== "toolCall") continue;
// Anthropic/OpenAI open a streamed tool block with an empty id (and
// `{}` args) before the id/arguments arrive; Gemini assembles the
// whole call first, so it never hits this. Keying `pendingTools` by
// "" would create a placeholder card, and the later real-id frame —
// `pendingTools.has(realId)` false — would create a SECOND card,
// orphaning the blank one (no `tool_execution_*` event ever carries
// "", so it is never matched, updated, or removed). Defer until the
// provider assigns the real id.
if (!content.id) continue;
if (content.name === "read") {
if (!readArgsHaveTarget(content.arguments)) {
// Args still streaming — defer until path is parseable so we can route to the
@@ -890,6 +899,13 @@ export class EventController {
}
async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
// A transient overlay (auto-compaction / auto-retry / handoff) that ran
// between this tool's start and end could have detached the working
// loader. `tool_execution_update` already reconciles this so the spinner
// reappears mid-tool; mirror it here so subagent (`task`) completions —
// which only fire `tool_execution_end`, never `_update` — do not leave
// the UI looking idle while the session keeps streaming (#3857).
this.#ensureWorkingLoaderWhileStreaming();
if (event.toolName === "read") {
if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -62,6 +62,16 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string,
}, timeoutMs);
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
}
function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createError: () => Error): Promise<T> {
if (signal.aborted) return Promise.reject(createError());
const aborted = Promise.withResolvers<never>();
const onAbort = (): void => aborted.reject(createError());
signal.addEventListener("abort", onAbort, { once: true });
return Promise.race([promise, aborted.promise]).finally(() => {
signal.removeEventListener("abort", onAbort);
});
}
/** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
export class MCPAuthorizationLinkPrompt implements Component {
@@ -135,6 +145,22 @@ interface OAuthFlowResult {
resource?: string;
}
/**
* Thrown by {@link MCPCommandController}'s OAuth handler when the user (or a
* caller-supplied {@link AbortSignal}) cancels the in-flight flow. Distinct
* from network/timeout failures so callers can surface a neutral
* "cancelled" status instead of an error banner.
*/
export class MCPOAuthCancelledError extends Error {
constructor(message = "OAuth flow cancelled") {
super(message);
this.name = "MCPOAuthCancelledError";
}
}
/** Reason recorded on the OAuth flow's AbortController when the user hits Esc. */
const MCP_OAUTH_USER_CANCEL_REASON = "MCP OAuth flow cancelled by user";
type MCPAddScope = "user" | "project";
type MCPAddTransport = "http" | "sse";
@@ -521,6 +547,10 @@ export class MCPCommandController {
userClientSecret: finalConfig.oauth?.clientSecret,
});
} catch (oauthError) {
if (oauthError instanceof MCPOAuthCancelledError) {
this.ctx.showStatus(`Add cancelled for "${parsed.initialName}"`);
return;
}
this.ctx.showError(
`OAuth flow failed for "${parsed.initialName}": ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`,
);
@@ -587,6 +617,14 @@ export class MCPCommandController {
serverUrl?: string;
resource?: string;
stripSameOriginResource?: boolean;
/**
* External cancellation source: when this signal aborts, the in-flight
* OAuth flow is torn down and {@link MCPOAuthCancelledError} is thrown.
* Wizards (which own focus and absorb Esc themselves) pass their own
* controller here; editor-focused callers rely on the Esc hook
* installed below instead.
*/
abortSignal?: AbortSignal;
},
): Promise<OAuthFlowResult> {
const authStorage = this.ctx.session.modelRegistry.authStorage;
@@ -614,6 +652,26 @@ export class MCPCommandController {
}
let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
const oauthTimeout = new AbortController();
// User Esc and external aborts route through here; the timeout path sets
// its own reason and leaves this flag false so the catch can distinguish
// "user cancelled" (status) from "deadline elapsed" (error).
let userCancelled = false;
const requestUserCancel = (reason: string): void => {
userCancelled = true;
if (!oauthTimeout.signal.aborted) oauthTimeout.abort(reason);
};
const originalOnEscape = this.ctx.editor.onEscape;
this.ctx.editor.onEscape = () => requestUserCancel(MCP_OAUTH_USER_CANCEL_REASON);
const externalSignal = opts?.abortSignal;
const onExternalAbort = (): void => {
const reason = externalSignal?.reason;
requestUserCancel(typeof reason === "string" ? reason : MCP_OAUTH_USER_CANCEL_REASON);
};
if (externalSignal?.aborted) {
onExternalAbort();
} else {
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
}
try {
// Create OAuth flow
const flow = new MCPOAuthFlow(
@@ -641,7 +699,7 @@ export class MCPCommandController {
block.addChild(new Spacer(1));
block.addChild(
new Text(
theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
theme.fg("muted", "Waiting for authorization... (Press Esc to cancel, 5 minute timeout)"),
1,
0,
),
@@ -687,9 +745,18 @@ export class MCPCommandController {
},
);
// Execute OAuth flow with 5 minute timeout
const createAbortError = (): Error => {
const reason = String(oauthTimeout.signal.reason ?? "MCP OAuth flow aborted");
return userCancelled ? new MCPOAuthCancelledError() : new Error(reason);
};
if (oauthTimeout.signal.aborted) throw createAbortError();
// Execute OAuth flow with 5 minute timeout. Race the login itself
// against the abort signal because Esc/external abort may fire before
// MCPOAuthFlow reaches OAuthCallbackFlow.#waitForCallback, where the
// underlying callback server normally observes the signal.
const credentials = await withTimeout(
flow.login(),
raceAbortSignal(flow.login(), oauthTimeout.signal, createAbortError),
5 * 60 * 1000,
"OAuth flow timed out after 5 minutes",
() => oauthTimeout.abort("MCP OAuth flow timed out"),
@@ -727,6 +794,14 @@ export class MCPCommandController {
resource: flow.resource,
};
} catch (error) {
// User-initiated cancel (Esc or external signal) → neutral status, not
// a failure. Check the flag we set in `requestUserCancel`, not the
// abort reason: the timeout path also aborts but with a different
// reason, and we want it to surface as a timeout error below.
if (userCancelled) {
throw new MCPOAuthCancelledError();
}
const errorMsg = error instanceof Error ? error.message : String(error);
// Provide helpful error messages based on failure type
@@ -742,6 +817,8 @@ export class MCPCommandController {
throw new Error(`OAuth authentication failed: ${errorMsg}`);
}
} finally {
this.ctx.editor.onEscape = originalOnEscape;
externalSignal?.removeEventListener("abort", onExternalAbort);
manualInputClaim?.clear("Manual MCP OAuth input cleared");
}
}
@@ -1629,6 +1706,10 @@ export class MCPCommandController {
];
this.#showMessage(lines.join("\n"));
} catch (error) {
if (error instanceof MCPOAuthCancelledError) {
this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
return;
}
this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -144,7 +144,7 @@ export class ToolArgsRevealController {
entry = {
component: undefined,
target: partialJson,
revealed: 0,
revealed: clampSliceEnd(partialJson, partialJson.length),
rawInput,
exposeRawPartialJson,
parsedArgs: {},
@@ -100,6 +100,7 @@ import { formatDuration } from "../slash-commands/helpers/format";
import { STTController, type SttState } from "../stt";
import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
import { formatTaskId } from "../task/render";
import type { ConfiguredThinkingLevel } from "../thinking";
import type { LspStartupServerInfo } from "../tools";
import { normalizeLocalScheme } from "../tools/path-utils";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
@@ -493,8 +494,8 @@ export class InteractiveMode implements InteractiveModeContext {
#goalTurnHadToolCalls = false;
#goalContinuationTurnInFlight = false;
#goalSuppressNextContinuation = false;
#planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
#pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
#planModePreviousModelState: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
#pendingModelSwitch: { model: Model; thinkingLevel?: ConfiguredThinkingLevel } | undefined;
#planModeHasEntered = false;
#planReviewOverlay: PlanReviewOverlay | undefined;
#planReviewOverlayHandle: OverlayHandle | undefined;
@@ -1917,7 +1918,7 @@ export class InteractiveMode implements InteractiveModeContext {
const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
this.#planModePreviousModelState = currentModel
? { model: currentModel, thinkingLevel: this.session.thinkingLevel }
? { model: currentModel, thinkingLevel: this.session.configuredThinkingLevel() }
: undefined;
if (!sameModel) {
@@ -2125,7 +2126,7 @@ export class InteractiveMode implements InteractiveModeContext {
});
}
async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ThinkingLevel }): Promise<void> {
async #restorePlanPreviousModel(prev: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }): Promise<void> {
if (modelsAreEqual(this.session.model, prev.model)) {
// Same model — only thinking level may differ. Avoid setModelTemporary()
// which would reset provider-side sessions and break continuity.
@@ -99,9 +99,9 @@ export function buildFileMentionBlock(files: FileMentionMessage["files"], indent
const block = new TranscriptBlock();
for (const file of files) {
let suffix: string;
if (file.skippedReason === "tooLarge") {
if (file.skippedReason === "tooLarge" || file.skippedReason === "binary") {
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
suffix = `(skipped: ${size})`;
suffix = file.skippedReason === "binary" ? `(skipped: binary, ${size})` : `(skipped: ${size})`;
} else {
suffix = file.image
? "(image)"