import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
import type { Component, OverlayHandle } 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";
import { getRoleInfo } from "../../config/model-registry";
import { formatModelSelectorValue } from "../../config/model-resolver";
import { settings } from "../../config/settings";
import { DebugSelectorComponent } from "../../debug";
import { disableProvider, enableProvider } from "../../discovery";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
import {
getInstalledPluginsRegistryPath,
getMarketplacesCacheDir,
getMarketplacesRegistryPath,
getPluginsCacheDir,
MarketplaceManager,
} from "../../extensibility/plugins/marketplace";
import {
getAvailableThemes,
getSymbolTheme,
previewTheme,
setColorBlindMode,
setSymbolPreset,
setTheme,
theme,
} from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import { type SessionInfo, SessionManager } from "../../session/session-manager";
import { FileSessionStorage } from "../../session/session-storage";
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
import {
isImageProviderPreference,
isSearchProviderPreference,
setPreferredImageProvider,
setPreferredSearchProvider,
} from "../../tools";
import { setSessionTerminalTitle } from "../../utils/title-generator";
import { AgentDashboard } from "../components/agent-dashboard";
import { AssistantMessageComponent } from "../components/assistant-message";
import { ExtensionDashboard } from "../components/extensions";
import { HistorySearchComponent } from "../components/history-search";
import { ModelSelectorComponent } from "../components/model-selector";
import { OAuthSelectorComponent } from "../components/oauth-selector";
import { PluginSelectorComponent } from "../components/plugin-selector";
import { SessionObserverOverlayComponent } from "../components/session-observer-overlay";
import { SessionSelectorComponent } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
import { ToolExecutionComponent } from "../components/tool-execution";
import { TreeSelectorComponent } from "../components/tree-selector";
import { UserMessageSelectorComponent } from "../components/user-message-selector";
import type { SessionObserverRegistry } from "../session-observer-registry";
const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
"anthropic",
"openai-codex",
"gitlab-duo",
"google-gemini-cli",
"google-antigravity",
]);
const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
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 => {
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"),
sessionAccent: settings.get("statusLine.sessionAccent"),
...previewSettings,
});
this.ctx.updateEditorTopBorder();
this.ctx.ui.requestRender();
},
getStatusLinePreview: () => {
const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
return this.ctx.statusLine.getTopBorder(availableWidth).content;
},
onPluginsChanged: () => {
this.ctx.ui.requestRender();
},
onCancel: () => {
done();
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"),
sessionAccent: settings.get("statusLine.sessionAccent"),
});
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();
};
dashboard.onRequestRender = () => {
this.ctx.ui.requestRender();
};
return { component: dashboard, focus: dashboard };
});
}
* Show the Agent Control Center dashboard.
*/
async showAgentsDashboard(): Promise<void> {
const activeModel = this.ctx.session.model;
const activeModelPattern = activeModel ? `${activeModel.provider}/${activeModel.id}` : undefined;
const defaultModelPattern = this.ctx.settings.getModelRole("default");
const dashboard = await AgentDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows, {
modelRegistry: this.ctx.session.modelRegistry,
activeModelPattern,
defaultModelPattern,
});
this.showSelector(done => {
dashboard.onClose = () => {
done();
this.ctx.ui.requestRender();
};
dashboard.onRequestRender = () => {
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 {
if (id.startsWith("discovery.")) {
const providerId = id.replace("discovery.", "");
if (value) {
enableProvider(providerId);
} else {
disableProvider(providerId);
}
return;
}
switch (id) {
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 ConfiguredThinkingLevel, true);
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
break;
case "clearOnShrink":
this.ctx.ui.setClearOnShrink(value as boolean);
break;
case "autocompleteMaxVisible":
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
break;
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;
this.ctx.session.agent.hideThinkingSummary = 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 "topP": {
const topP = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.topP = topP >= 0 ? topP : undefined;
break;
}
case "topK": {
const topK = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.topK = topK >= 0 ? topK : undefined;
break;
}
case "minP": {
const minP = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.minP = minP >= 0 ? minP : undefined;
break;
}
case "presencePenalty": {
const presencePenalty = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.presencePenalty = presencePenalty >= 0 ? presencePenalty : undefined;
break;
}
case "repetitionPenalty": {
const repetitionPenalty = typeof value === "number" ? value : Number(value);
this.ctx.session.agent.repetitionPenalty = repetitionPenalty >= 0 ? repetitionPenalty : undefined;
break;
}
case "statusLinePreset":
case "statusLine.preset":
case "statusLineSeparator":
case "statusLine.separator":
case "statusLineShowHooks":
case "statusLine.showHookStatus":
case "statusLine.sessionAccent":
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"),
sessionAccent: settings.get("statusLine.sessionAccent"),
segmentOptions: settings.get("statusLine.segmentOptions"),
};
this.ctx.statusLine.updateSettings(statusLineSettings);
this.ctx.updateEditorTopBorder();
this.ctx.ui.requestRender();
break;
}
case "providers.webSearch":
if (typeof value === "string" && isSearchProviderPreference(value)) {
setPreferredSearchProvider(value);
}
break;
case "providers.image":
if (isImageProviderPreference(value)) {
setPreferredImageProvider(value);
}
break;
case "mcp.notifications":
this.ctx.mcpManager?.setNotificationsEnabled(value as boolean);
break;
}
}
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, thinkingLevel, selector) => {
const isAuto = thinkingLevel === AUTO_THINKING;
const concreteThinking = isAuto ? undefined : thinkingLevel;
try {
if (role === null) {
await this.ctx.session.setModelTemporary(model);
if (isAuto) {
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
}
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`);
done();
this.ctx.ui.requestRender();
} else if (role === "default") {
await this.ctx.session.setModel(model, role, {
selector,
thinkingLevel: concreteThinking,
persist: true,
});
if (isAuto) {
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
} else if (concreteThinking && concreteThinking !== ThinkingLevel.Inherit) {
this.ctx.session.setThinkingLevel(concreteThinking);
}
this.ctx.statusLine.invalidate();
this.ctx.updateEditorBorderColor();
this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
} else {
this.ctx.settings.setModelRole(
role,
formatModelSelectorValue(selector ?? `${model.provider}/${model.id}`, concreteThinking),
);
if (isAuto) {
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
}
const roleInfo = getRoleInfo(role, settings);
const roleLabel = roleInfo?.name ?? role;
this.ctx.showStatus(`${roleLabel} model: ${selector ?? model.id}`);
}
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
() => {
done();
this.ctx.ui.requestRender();
},
options,
);
return { component: selector, focus: selector };
});
}
async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise<void> {
const mgr = new MarketplaceManager({
marketplacesRegistryPath: getMarketplacesRegistryPath(),
installedRegistryPath: getInstalledPluginsRegistryPath(),
projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
marketplacesCacheDir: getMarketplacesCacheDir(),
pluginsCacheDir: getPluginsCacheDir(),
clearPluginRootsCache: clearPluginRootsAndCaches,
});
const [marketplaces, installed] = await Promise.all([mgr.listMarketplaces(), mgr.listInstalledPlugins()]);
const installedIds = new Set(installed.map(p => p.id));
if (mode === "uninstall") {
const items = installed.map(p => {
const entry = p.entries[0];
const atIdx = p.id.lastIndexOf("@");
const pluginName = atIdx > 0 ? p.id.slice(0, atIdx) : p.id;
const mkt = atIdx > 0 ? p.id.slice(atIdx + 1) : "unknown";
return {
plugin: { name: pluginName, version: entry?.version, description: undefined as string | undefined },
marketplace: mkt,
scope: p.scope,
};
});
this.showSelector(done => {
const selector = new PluginSelectorComponent(marketplaces.length, items, new Set(), {
onSelect: async (name, marketplace, scope) => {
done();
const pluginId = `${name}@${marketplace}`;
this.ctx.showStatus(`Uninstalling ${pluginId}...`);
this.ctx.ui.requestRender();
try {
await mgr.uninstallPlugin(pluginId, scope);
this.ctx.showStatus(`Uninstalled ${pluginId}`);
} catch (err) {
this.ctx.showStatus(`Uninstall failed: ${err}`);
}
this.ctx.ui.requestRender();
},
onCancel: () => {
done();
this.ctx.ui.requestRender();
},
});
return { component: selector, focus: selector.getSelectList() };
});
return;
}
const allPlugins: Array<{
plugin: { name: string; version?: string; description?: string };
marketplace: string;
}> = [];
for (const mkt of marketplaces) {
const plugins = await mgr.listAvailablePlugins(mkt.name);
for (const plugin of plugins) {
allPlugins.push({ plugin, marketplace: mkt.name });
}
}
this.showSelector(done => {
const selector = new PluginSelectorComponent(marketplaces.length, allPlugins, installedIds, {
onSelect: async (name, marketplace) => {
done();
this.ctx.showStatus(`Installing ${name} from ${marketplace}...`);
this.ctx.ui.requestRender();
try {
const force = installedIds.has(`${name}@${marketplace}`);
await mgr.installPlugin(name, marketplace, { force });
this.ctx.showStatus(`Installed ${name} from ${marketplace}`);
} catch (err) {
this.ctx.showStatus(`Install failed: ${err}`);
}
this.ctx.ui.requestRender();
},
onCancel: () => {
done();
this.ctx.ui.requestRender();
},
});
return { component: selector, focus: selector.getSelectList() };
});
}
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) {
done();
this.ctx.ui.requestRender();
return;
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
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 => {
if (entryId === realLeafId) {
done();
this.ctx.showStatus("Already at this point");
return;
}
done();
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) {
this.showTreeSelector();
return;
}
wantsSummary = summaryChoice !== "No summary";
if (summaryChoice === "Summarize with custom prompt") {
customInstructions = await this.ctx.showHookEditor("Custom summarization instructions");
if (customInstructions === undefined) {
continue;
}
}
break;
}
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) {
this.ctx.showStatus("Branch summarization cancelled");
this.showTreeSelector();
return;
}
if (result.cancelled) {
this.ctx.showStatus("Navigation cancelled");
return;
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages(result.sessionContext, { clearTerminalHistory: true });
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();
},
settings.get("treeFilterMode"),
);
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();
},
async (session: SessionInfo) => {
if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
return false;
}
const storage = new FileSessionStorage();
try {
await storage.deleteSessionWithArtifacts(session.path);
return true;
} catch (err) {
throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
cause: err,
});
}
},
);
selector.setOnRequestRender(() => this.ctx.ui.requestRender());
return { component: selector, focus: selector };
});
}
#clearTransientSessionUi(): void {
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
}
#refreshSessionTerminalTitle(): void {
const sessionManager = this.ctx.sessionManager as {
getSessionName?: () => string | undefined;
getCwd: () => string;
titleSource?: "auto" | "user" | undefined;
};
setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd());
}
async #detachActiveSessionBeforeDeletion(sessionPath: string): Promise<boolean> {
const currentSessionFile = this.ctx.sessionManager.getSessionFile();
if (currentSessionFile !== sessionPath) {
return true;
}
const detached = await this.ctx.session.newSession();
if (!detached) {
return false;
}
this.#refreshSessionTerminalTitle();
this.#clearTransientSessionUi();
this.ctx.statusLine.invalidate();
this.ctx.statusLine.setSessionStartTime(Date.now());
this.ctx.updateEditorTopBorder();
this.ctx.updateEditorBorderColor();
this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.ui.requestRender(true, { clearScrollback: true });
return true;
}
async handleResumeSession(sessionPath: string): Promise<void> {
this.#clearTransientSessionUi();
await this.ctx.session.switchSession(sessionPath);
this.#refreshSessionTerminalTitle();
this.ctx.updateEditorBorderColor();
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus("Resumed session");
}
async handleSessionDeleteCommand(): Promise<void> {
const sessionFile = this.ctx.sessionManager.getSessionFile();
if (!sessionFile) {
this.ctx.showError("No session file to delete (in-memory session)");
return;
}
const storage = new FileSessionStorage();
const fileExists = await storage.exists(sessionFile);
if (!fileExists) {
this.ctx.showError("Session has not been saved yet");
return;
}
const confirmed = await this.ctx.showHookConfirm(
"Delete Session",
"This will permanently delete the current session.\nYou will be returned to the session selector.",
);
if (!confirmed) {
this.ctx.showStatus("Delete cancelled");
return;
}
if (!(await this.#detachActiveSessionBeforeDeletion(sessionFile))) {
this.ctx.showStatus("Delete cancelled");
return;
}
await storage.deleteSessionWithArtifacts(sessionFile);
this.ctx.showStatus("Session deleted");
await this.showSessionSelector();
}
async #handleOAuthLogin(providerId: string): Promise<void> {
this.ctx.showStatus(`Logging in to ${providerId}…`);
const manualInput = this.ctx.oauthManualInput;
const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
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));
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));
}
if (useManualInput) {
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 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();
const { promise, resolve } = Promise.withResolvers<string>();
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();
return promise;
},
onProgress: (message: string) => {
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.ctx.ui.requestRender();
},
onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
});
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)}`);
} finally {
if (useManualInput) {
manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
}
}
}
async #handleOAuthLogout(providerId: string): Promise<void> {
try {
await this.ctx.session.modelRegistry.authStorage.logout(providerId);
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)}`);
}
}
async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
if (providerId) {
if (mode === "login") {
await this.#handleOAuthLogin(providerId);
} else {
await this.#handleOAuthLogout(providerId);
}
return;
}
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 (selectedProviderId: string) => {
selector.stopValidation();
done();
if (mode === "login") {
await this.#handleOAuthLogin(selectedProviderId);
} else {
await this.#handleOAuthLogout(selectedProviderId);
}
},
() => {
selector.stopValidation();
done();
this.ctx.ui.requestRender();
},
{
validateAuth: async (selectedProviderId: string) => {
const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
selectedProviderId,
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 };
});
}
showSessionObserver(registry: SessionObserverRegistry): void {
const observeKeys = this.ctx.keybindings.getKeys("app.session.observe");
let cleanup: (() => void) | undefined;
let overlayHandle: OverlayHandle | undefined;
const done = () => {
cleanup?.();
overlayHandle?.hide();
this.ctx.ui.requestRender();
};
const selector = new SessionObserverOverlayComponent(registry, done, observeKeys);
cleanup = registry.onChange(() => {
selector.refreshFromRegistry();
this.ctx.ui.requestRender();
});
overlayHandle = this.ctx.ui.showOverlay(selector, {
anchor: "bottom-center",
width: "100%",
maxHeight: "100%",
margin: 0,
});
this.ctx.ui.setFocus(selector);
this.ctx.ui.requestRender();
}
}