acf4b45062
packages/ai: - fix: improve GitHub Copilot OAuth polling and Codex stream recovery - fix: allow google-vertex authentication via GOOGLE_CLOUD_API_KEY - fix: send Gemini/Claude provider-specific thinking headers and thought-signature fallbacks correctly - test: added coverage for Gemini CLI alignment, Codex streaming, and stream edge cases packages/coding-agent: - feat: add treeFilterMode setting for the session tree selector default - fix: prefer later-loaded explicit extensions when commands conflict - fix: truncate serialized tool results during compaction summarization to prevent overflow - fix: normalize CRLF in write tool previews - fix: use shell-based external editor launch on Windows - test: added compaction serialization and extension runner precedence coverage packages/tui: - fix: chain slash-command argument autocomplete after tab-completing the command name - fix: normalize pasted tabs in Input using configured indentation - fix: render blockquote lists, tables, and fenced code blocks correctly - fix: ignore unsupported Kitty CSI-u modifiers and enable modifyOtherKeys fallback cleanup - test: added editor, input, keys, and markdown regression coverage
784 lines
25 KiB
TypeScript
784 lines
25 KiB
TypeScript
import { 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";
|
|
import { MODEL_ROLES } from "../../config/model-registry";
|
|
import { settings } from "../../config/settings";
|
|
import { DebugSelectorComponent } from "../../debug";
|
|
import { disableProvider, enableProvider } from "../../discovery";
|
|
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";
|
|
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 { 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";
|
|
|
|
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 => {
|
|
// 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 availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
|
|
return this.ctx.statusLine.getTopBorder(availableWidth).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 };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
// 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;
|
|
|
|
case "autocompleteMaxVisible":
|
|
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
|
|
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 "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 "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 "providers.webSearch":
|
|
setPreferredSearchProvider(
|
|
value as
|
|
| "auto"
|
|
| "exa"
|
|
| "brave"
|
|
| "jina"
|
|
| "kimi"
|
|
| "zai"
|
|
| "perplexity"
|
|
| "anthropic"
|
|
| "gemini"
|
|
| "codex"
|
|
| "kagi"
|
|
| "synthetic",
|
|
);
|
|
break;
|
|
case "providers.image":
|
|
setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
|
|
break;
|
|
|
|
// MCP update injection - live subscribe/unsubscribe
|
|
case "mcp.notifications":
|
|
this.ctx.mcpManager?.setNotificationsEnabled(value as boolean);
|
|
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, thinkingLevel) => {
|
|
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);
|
|
if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
|
|
this.ctx.session.setThinkingLevel(thinkingLevel);
|
|
}
|
|
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();
|
|
},
|
|
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();
|
|
},
|
|
);
|
|
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 #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 };
|
|
});
|
|
}
|
|
}
|