Files
oh-my-pi/packages/coding-agent/src/modes/controllers/selector-controller.ts
T
can1357 a39702f003 feat(web-search): added Z.AI web search provider with MCP integration
- Added Z.AI web search provider with MCP integration for remote web search capabilities.
- Added 'zai' as a selectable web search provider option in settings and CLI.
- Added Z.AI to the automatic provider fallback chain for web search after Gemini and Codex.
- Implemented ZaiProvider class with support for multiple Z.AI API response formats and retry logic.
- Added comprehensive error handling and response parsing for Z.AI MCP endpoint integration.
2026-02-16 05:22:50 +01:00

684 lines
22 KiB
TypeScript

import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
import { MODEL_ROLES } from "../../config/model-registry";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
import { disableProvider, enableProvider } from "../../discovery";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { ExtensionDashboard } from "../../modes/components/extensions";
import { HistorySearchComponent } from "../../modes/components/history-search";
import { ModelSelectorComponent } from "../../modes/components/model-selector";
import { OAuthSelectorComponent } from "../../modes/components/oauth-selector";
import { SessionSelectorComponent } from "../../modes/components/session-selector";
import { SettingsSelectorComponent } from "../../modes/components/settings-selector";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
import { TreeSelectorComponent } from "../../modes/components/tree-selector";
import { UserMessageSelectorComponent } from "../../modes/components/user-message-selector";
import {
getAvailableThemes,
getSymbolTheme,
previewTheme,
setColorBlindMode,
setSymbolPreset,
setTheme,
theme,
} from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import { SessionManager } from "../../session/session-manager";
import { setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
export class SelectorController {
constructor(private ctx: InteractiveModeContext) {}
async #refreshOAuthProviderAuthState(): Promise<void> {
const oauthProviders = getOAuthProviders();
await Promise.all(
oauthProviders.map(provider =>
this.ctx.session.modelRegistry
.getApiKeyForProvider(provider.id, this.ctx.session.sessionId)
.catch(() => undefined),
),
);
}
/**
* Shows a selector component in place of the editor.
* @param create Factory that receives a `done` callback and returns the component and focus target
*/
showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {
const done = () => {
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(this.ctx.editor);
this.ctx.ui.setFocus(this.ctx.editor);
};
const { component, focus } = create(done);
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(component);
this.ctx.ui.setFocus(focus);
this.ctx.ui.requestRender();
}
showSettingsSelector(): void {
getAvailableThemes().then(availableThemes => {
this.showSelector(done => {
const selector = new SettingsSelectorComponent(
{
availableThinkingLevels: this.ctx.session.getAvailableThinkingLevels(),
thinkingLevel: this.ctx.session.thinkingLevel,
availableThemes,
cwd: getProjectDir(),
},
{
onChange: (id, value) => this.handleSettingChange(id, value),
onThemePreview: async themeName => {
const result = await previewTheme(themeName);
if (result.success) {
this.ctx.statusLine.invalidate();
this.ctx.updateEditorTopBorder();
this.ctx.ui.invalidate();
this.ctx.ui.requestRender();
}
},
onStatusLinePreview: previewSettings => {
// Update status line with preview settings
this.ctx.statusLine.updateSettings({
preset: settings.get("statusLine.preset"),
leftSegments: settings.get("statusLine.leftSegments"),
rightSegments: settings.get("statusLine.rightSegments"),
separator: settings.get("statusLine.separator"),
showHookStatus: settings.get("statusLine.showHookStatus"),
...previewSettings,
});
this.ctx.updateEditorTopBorder();
this.ctx.ui.requestRender();
},
getStatusLinePreview: () => {
// Return the rendered status line for inline preview
const width = this.ctx.ui.terminal.columns;
return this.ctx.statusLine.getTopBorder(width).content;
},
onPluginsChanged: () => {
this.ctx.ui.requestRender();
},
onCancel: () => {
done();
// Restore status line to saved settings
this.ctx.statusLine.updateSettings({
preset: settings.get("statusLine.preset"),
leftSegments: settings.get("statusLine.leftSegments"),
rightSegments: settings.get("statusLine.rightSegments"),
separator: settings.get("statusLine.separator"),
showHookStatus: settings.get("statusLine.showHookStatus"),
});
this.ctx.updateEditorTopBorder();
this.ctx.ui.requestRender();
},
},
);
return { component: selector, focus: selector };
});
});
}
showHistorySearch(): void {
const historyStorage = this.ctx.historyStorage;
if (!historyStorage) return;
this.showSelector(done => {
const component = new HistorySearchComponent(
historyStorage,
prompt => {
done();
this.ctx.editor.setText(prompt);
this.ctx.ui.requestRender();
},
() => {
done();
this.ctx.ui.requestRender();
},
);
return { component, focus: component };
});
}
/**
* Show the Extension Control Center dashboard.
* Replaces /status with a unified view of all providers and extensions.
*/
async showExtensionsDashboard(): Promise<void> {
const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
this.showSelector(done => {
dashboard.onClose = () => {
done();
this.ctx.ui.requestRender();
};
return { component: dashboard, focus: dashboard };
});
}
/**
* Handle setting changes from the settings selector.
* Most settings are saved directly via SettingsManager in the definitions.
* This handles side effects and session-specific settings.
*/
handleSettingChange(id: string, value: unknown): void {
// Discovery provider toggles
if (id.startsWith("discovery.")) {
const providerId = id.replace("discovery.", "");
if (value) {
enableProvider(providerId);
} else {
disableProvider(providerId);
}
return;
}
switch (id) {
// Session-managed settings (not in SettingsManager)
case "autoCompact":
this.ctx.session.setAutoCompactionEnabled(value as boolean);
this.ctx.statusLine.setAutoCompactEnabled(value as boolean);
break;
case "steeringMode":
this.ctx.session.setSteeringMode(value as "all" | "one-at-a-time");
break;
case "followUpMode":
this.ctx.session.setFollowUpMode(value as "all" | "one-at-a-time");
break;
case "interruptMode":
this.ctx.session.setInterruptMode(value as "immediate" | "wait");
break;
case "thinkingLevel":
case "defaultThinkingLevel":
this.ctx.session.setThinkingLevel(value as ThinkingLevel, true);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
break;
case "clearOnShrink":
this.ctx.ui.setClearOnShrink(value as boolean);
break;
// Settings with UI side effects
case "showImages":
for (const child of this.ctx.chatContainer.children) {
if (child instanceof ToolExecutionComponent) {
child.setShowImages(value as boolean);
}
}
break;
case "hideThinking":
this.ctx.hideThinkingBlock = value as boolean;
for (const child of this.ctx.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(value as boolean);
}
}
this.ctx.chatContainer.clear();
this.ctx.rebuildChatFromMessages();
break;
case "theme": {
setTheme(value as string, true).then(result => {
this.ctx.statusLine.invalidate();
this.ctx.updateEditorTopBorder();
this.ctx.ui.invalidate();
if (!result.success) {
this.ctx.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
}
});
break;
}
case "symbolPreset": {
setSymbolPreset(value as "unicode" | "nerd" | "ascii").then(() => {
this.ctx.statusLine.invalidate();
this.ctx.updateEditorTopBorder();
this.ctx.ui.invalidate();
});
break;
}
case "colorBlindMode": {
setColorBlindMode(value === "true" || value === true).then(() => {
this.ctx.ui.invalidate();
});
break;
}
case "temperature": {
const temp = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.temperature = temp >= 0 ? temp : undefined;
break;
}
case "statusLinePreset":
case "statusLineSeparator":
case "statusLineShowHooks":
case "statusLineSegments":
case "statusLineModelThinking":
case "statusLinePathAbbreviate":
case "statusLinePathMaxLength":
case "statusLinePathStripWorkPrefix":
case "statusLineGitShowBranch":
case "statusLineGitShowStaged":
case "statusLineGitShowUnstaged":
case "statusLineGitShowUntracked":
case "statusLineTimeFormat":
case "statusLineTimeShowSeconds": {
const statusLineSettings = {
preset: settings.get("statusLine.preset"),
leftSegments: settings.get("statusLine.leftSegments"),
rightSegments: settings.get("statusLine.rightSegments"),
separator: settings.get("statusLine.separator"),
showHookStatus: settings.get("statusLine.showHookStatus"),
segmentOptions: settings.get("statusLine.segmentOptions"),
};
this.ctx.statusLine.updateSettings(statusLineSettings);
this.ctx.updateEditorTopBorder();
this.ctx.ui.requestRender();
break;
}
// Provider settings - update runtime preferences
case "webSearchProvider":
setPreferredSearchProvider(
value as "auto" | "exa" | "jina" | "zai" | "perplexity" | "anthropic" | "gemini" | "codex",
);
break;
case "imageProvider":
setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
break;
// All other settings are handled by the definitions (get/set on SettingsManager)
// No additional side effects needed
}
}
showModelSelector(options?: { temporaryOnly?: boolean }): void {
this.showSelector(done => {
const selector = new ModelSelectorComponent(
this.ctx.ui,
this.ctx.session.model,
this.ctx.settings,
this.ctx.session.modelRegistry,
this.ctx.session.scopedModels,
async (model, role) => {
try {
if (role === null) {
// Temporary: update agent state but don't persist to settings
await this.ctx.session.setModelTemporary(model);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
this.ctx.showStatus(`Temporary model: ${model.id}`);
done();
this.ctx.ui.requestRender();
} else if (role === "default") {
// Default: update agent state and persist
await this.ctx.session.setModel(model, role);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
this.ctx.showStatus(`Default model: ${model.id}`);
// Don't call done() - selector stays open for role assignment
} else {
// Other roles (smol, slow): just update settings, not current model
const roleInfo = MODEL_ROLES[role];
const roleLabel = roleInfo?.name ?? role;
this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
// Don't call done() - selector stays open
}
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
() => {
done();
this.ctx.ui.requestRender();
},
options,
);
return { component: selector, focus: selector };
});
}
showUserMessageSelector(): void {
const userMessages = this.ctx.session.getUserMessagesForBranching();
if (userMessages.length === 0) {
this.ctx.showStatus("No messages to branch from");
return;
}
this.showSelector(done => {
const selector = new UserMessageSelectorComponent(
userMessages.map(m => ({ id: m.entryId, text: m.text })),
async entryId => {
const result = await this.ctx.session.branch(entryId);
if (result.cancelled) {
// Hook cancelled the branch
done();
this.ctx.ui.requestRender();
return;
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
this.ctx.editor.setText(result.selectedText);
done();
this.ctx.showStatus("Branched to new session");
},
() => {
done();
this.ctx.ui.requestRender();
},
);
return { component: selector, focus: selector.getMessageList() };
});
}
showTreeSelector(): void {
const tree = this.ctx.sessionManager.getTree();
const realLeafId = this.ctx.sessionManager.getLeafId();
if (tree.length === 0) {
this.ctx.showStatus("No entries in session");
return;
}
this.showSelector(done => {
const selector = new TreeSelectorComponent(
tree,
realLeafId,
this.ctx.ui.terminal.rows,
async entryId => {
// Selecting the current leaf is a no-op (already there)
if (entryId === realLeafId) {
done();
this.ctx.showStatus("Already at this point");
return;
}
// Ask about summarization
done(); // Close selector first
// Loop until user makes a complete choice or cancels to tree
let wantsSummary = false;
let customInstructions: string | undefined;
const branchSummariesEnabled = settings.get("branchSummary.enabled");
while (branchSummariesEnabled) {
const summaryChoice = await this.ctx.showHookSelector("Summarize branch?", [
"No summary",
"Summarize",
"Summarize with custom prompt",
]);
if (summaryChoice === undefined) {
// User pressed escape - re-show tree selector
this.showTreeSelector();
return;
}
wantsSummary = summaryChoice !== "No summary";
if (summaryChoice === "Summarize with custom prompt") {
customInstructions = await this.ctx.showHookEditor("Custom summarization instructions");
if (customInstructions === undefined) {
// User cancelled - loop back to summary selector
continue;
}
}
// User made a complete choice
break;
}
// Set up escape handler and loader if summarizing
let summaryLoader: Loader | undefined;
const originalOnEscape = this.ctx.editor.onEscape;
if (wantsSummary) {
this.ctx.editor.onEscape = () => {
this.ctx.session.abortBranchSummary();
};
this.ctx.chatContainer.addChild(new Spacer(1));
summaryLoader = new Loader(
this.ctx.ui,
spinner => theme.fg("accent", spinner),
text => theme.fg("muted", text),
"Summarizing branch... (esc to cancel)",
getSymbolTheme().spinnerFrames,
);
this.ctx.statusContainer.addChild(summaryLoader);
this.ctx.ui.requestRender();
}
try {
const result = await this.ctx.session.navigateTree(entryId, {
summarize: wantsSummary,
customInstructions,
});
if (result.aborted) {
// Summarization aborted - re-show tree selector
this.ctx.showStatus("Branch summarization cancelled");
this.showTreeSelector();
return;
}
if (result.cancelled) {
this.ctx.showStatus("Navigation cancelled");
return;
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
} finally {
if (summaryLoader) {
summaryLoader.stop();
this.ctx.statusContainer.clear();
}
this.ctx.editor.onEscape = originalOnEscape;
}
},
() => {
done();
this.ctx.ui.requestRender();
},
(entryId, label) => {
this.ctx.sessionManager.appendLabelChange(entryId, label);
this.ctx.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
async showSessionSelector(): Promise<void> {
const sessions = await SessionManager.list(
this.ctx.sessionManager.getCwd(),
this.ctx.sessionManager.getSessionDir(),
);
this.showSelector(done => {
const selector = new SessionSelectorComponent(
sessions,
async sessionPath => {
done();
await this.handleResumeSession(sessionPath);
},
() => {
done();
this.ctx.ui.requestRender();
},
() => {
void this.ctx.shutdown();
},
);
return { component: selector, focus: selector.getSessionList() };
});
}
async handleResumeSession(sessionPath: string): Promise<void> {
// Stop loading animation
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
// Clear UI state
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
// Switch session via AgentSession (emits hook and tool session events)
await this.ctx.session.switchSession(sessionPath);
// Clear and re-render the chat
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
this.ctx.showStatus("Resumed session");
}
async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "logout") {
await this.#refreshOAuthProviderAuthState();
const oauthProviders = getOAuthProviders();
const loggedInProviders = oauthProviders.filter(provider =>
this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id),
);
if (loggedInProviders.length === 0) {
this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
return;
}
}
this.showSelector(done => {
let selector: OAuthSelectorComponent;
selector = new OAuthSelectorComponent(
mode,
this.ctx.session.modelRegistry.authStorage,
async (providerId: string) => {
selector.stopValidation();
done();
if (mode === "login") {
this.ctx.showStatus(`Logging in to ${providerId}…`);
try {
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
onAuth: (info: { url: string; instructions?: string }) => {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
// Use OSC 8 hyperlink escape sequence for clickable link
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
if (info.instructions) {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
}
this.ctx.ui.requestRender();
this.ctx.openInBrowser(info.url);
},
onPrompt: async (prompt: { message: string; placeholder?: string }) => {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
if (prompt.placeholder) {
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
}
this.ctx.ui.requestRender();
return new Promise<string>(resolve => {
const codeInput = new Input();
codeInput.onSubmit = () => {
const code = codeInput.getValue();
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(this.ctx.editor);
this.ctx.ui.setFocus(this.ctx.editor);
resolve(code);
};
this.ctx.editorContainer.clear();
this.ctx.editorContainer.addChild(codeInput);
this.ctx.ui.setFocus(codeInput);
this.ctx.ui.requestRender();
});
},
onProgress: (message: string) => {
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.ctx.ui.requestRender();
},
});
// Refresh models to pick up new baseUrl (e.g., github-copilot)
await this.ctx.session.modelRegistry.refresh();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(
theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`),
1,
0,
),
);
this.ctx.chatContainer.addChild(
new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0),
);
this.ctx.ui.requestRender();
} catch (error: unknown) {
this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
try {
await this.ctx.session.modelRegistry.authStorage.logout(providerId);
// Refresh models to reset baseUrl
await this.ctx.session.modelRegistry.refresh();
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(
new Text(
theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`),
1,
0,
),
);
this.ctx.chatContainer.addChild(
new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
);
this.ctx.ui.requestRender();
} catch (error: unknown) {
this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
},
() => {
selector.stopValidation();
done();
this.ctx.ui.requestRender();
},
{
validateAuth: async (providerId: string) => {
const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
providerId,
this.ctx.session.sessionId,
);
return !!apiKey;
},
requestRender: () => {
this.ctx.ui.requestRender();
},
},
);
return { component: selector, focus: selector };
});
}
showDebugSelector(): void {
this.showSelector(done => {
const selector = new DebugSelectorComponent(this.ctx, done);
return { component: selector, focus: selector };
});
}
}