Merge remote-tracking branch 'upstream/main' into feat/secret-friendly-names
This commit is contained in:
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user