1597 lines
55 KiB
TypeScript
1597 lines
55 KiB
TypeScript
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
|
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
|
|
import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
|
|
import { Loader, Spacer, setTuiTight, Text } from "@oh-my-pi/pi-tui";
|
|
import { getAgentDbPath, getAgentDir, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
|
|
import {
|
|
type AdvisorConfigScope,
|
|
discoverAdvisorConfigs,
|
|
loadWatchdogConfigFile,
|
|
resolveAdvisorConfigEditPath,
|
|
saveWatchdogConfigFile,
|
|
} from "../../advisor";
|
|
import { formatModelSelectorValue, resolveAdvisorRoleSelection } from "../../config/model-resolver";
|
|
import { getRoleInfo } from "../../config/model-roles";
|
|
import { settings } from "../../config/settings";
|
|
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,
|
|
setMarkdownMermaidRendering,
|
|
setSymbolPreset,
|
|
setTheme,
|
|
theme,
|
|
} from "../../modes/theme/theme";
|
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage";
|
|
import type { SessionInfo } from "../../session/session-listing";
|
|
import { SessionManager } from "../../session/session-manager";
|
|
import { FileSessionStorage } from "../../session/session-storage";
|
|
import { type LogoutAccount, toLogoutAccounts } from "../../slash-commands/helpers/logout";
|
|
import {
|
|
describeRedeemOutcome,
|
|
type ResetUsageAccount,
|
|
toResetUsageAccounts,
|
|
} from "../../slash-commands/helpers/reset-usage";
|
|
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
|
|
import {
|
|
isImageProviderPreference,
|
|
isSearchProviderId,
|
|
isSearchProviderPreference,
|
|
setExcludedSearchProviders,
|
|
setPreferredImageProvider,
|
|
setPreferredSearchProvider,
|
|
} from "../../tools";
|
|
import { shortenPath } from "../../tools/render-utils";
|
|
import { copyToClipboard } from "../../utils/clipboard";
|
|
import { repo } from "../../utils/git";
|
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
import { type AdvisorConfigDeps, AdvisorConfigOverlayComponent } from "../components/advisor-config";
|
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
import { AgentHubOverlayComponent } from "../components/agent-hub";
|
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
import { CopySelectorComponent } from "../components/copy-selector";
|
|
import { ExtensionDashboard } from "../components/extensions";
|
|
import { HistorySearchComponent } from "../components/history-search";
|
|
import { LoginDialogComponent } from "../components/login-dialog";
|
|
import { LogoutAccountSelectorComponent } from "../components/logout-account-selector";
|
|
import { ModelHubComponent } from "../components/model-hub";
|
|
import { ModelPickerComponent } from "../components/model-picker";
|
|
import { OAuthSelectorComponent } from "../components/oauth-selector";
|
|
import { PluginSelectorComponent } from "../components/plugin-selector";
|
|
import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
|
|
import { renderSegmentTrack } from "../components/segment-track";
|
|
import { SessionSelectorComponent } from "../components/session-selector";
|
|
import { SettingsSelectorComponent } from "../components/settings-selector";
|
|
import { ToolExecutionComponent } from "../components/tool-execution";
|
|
import { TranscriptBlock } from "../components/transcript-container";
|
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
import { buildCopyTargets } from "../utils/copy-targets";
|
|
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Restore keyboard focus to whatever currently owns the editor slot. The
|
|
* slot can hold the editor itself or a hook selector/input/editor pushed
|
|
* in by `ExtensionUiController` — e.g. an approval prompt that fired while
|
|
* a fullscreen overlay was up. `overlayHandle.hide()` restores focus to
|
|
* the component focused when the overlay opened, which is stale in that
|
|
* case (the editor was swapped out): keys land on a hidden editor and the
|
|
* visible prompt receives nothing (issue #3349). Call this after the
|
|
* overlay hides to re-target focus at the visible slot owner.
|
|
*/
|
|
focusActiveEditorArea(): void {
|
|
const visible = this.ctx.editorContainer.children[0] ?? this.ctx.editor;
|
|
this.ctx.ui.setFocus(visible);
|
|
}
|
|
|
|
/**
|
|
* 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 => {
|
|
// Fullscreen settings editor on the alternate screen: the overlay
|
|
// enables mouse tracking (click/hover/wheel) for its lifetime and
|
|
// the transcript stays untouched underneath.
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
const done = () => {
|
|
overlayHandle?.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
const selector = new SettingsSelectorComponent(
|
|
{
|
|
availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()],
|
|
thinkingLevel: this.ctx.session.thinkingLevel,
|
|
availableThemes,
|
|
providers: [...new Set(this.ctx.session.getAvailableModels().map(model => model.provider))].sort(
|
|
(a, b) => a.localeCompare(b),
|
|
),
|
|
cwd: getProjectDir(),
|
|
model: this.ctx.session.model,
|
|
imageBudget: this.ctx.ui.imageBudget,
|
|
requestRender: () => this.ctx.ui.requestRender(),
|
|
},
|
|
{
|
|
onChange: (id, value) => this.handleSettingChange(id, value),
|
|
onThemePreview: async themeName => {
|
|
const result = await previewTheme(themeName);
|
|
if (result.success) {
|
|
this.ctx.statusLine.invalidate();
|
|
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"),
|
|
sessionAccent: settings.get("statusLine.sessionAccent"),
|
|
transparent: settings.get("statusLine.transparent"),
|
|
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
|
|
...previewSettings,
|
|
});
|
|
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: async () => {
|
|
const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
|
|
clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
|
|
await this.ctx.refreshSlashCommandState();
|
|
await this.ctx.session.refreshSshTool({ activateIfAvailable: true });
|
|
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"),
|
|
sessionAccent: settings.get("statusLine.sessionAccent"),
|
|
transparent: settings.get("statusLine.transparent"),
|
|
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
|
|
});
|
|
this.ctx.ui.requestRender();
|
|
},
|
|
},
|
|
);
|
|
overlayHandle = this.ctx.ui.showOverlay(selector, {
|
|
anchor: "bottom-center",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
fullscreen: true,
|
|
});
|
|
this.ctx.ui.setFocus(selector);
|
|
this.ctx.ui.requestRender();
|
|
});
|
|
}
|
|
|
|
showAdvisorConfigure(): void {
|
|
const cwd = this.ctx.sessionManager.getCwd();
|
|
const agentDir = getAgentDir() ?? getProjectDir();
|
|
const initialScope: AdvisorConfigScope = "project";
|
|
void (async () => {
|
|
// "Project" scope edits the repo-root WATCHDOG.yml (the project-level file
|
|
// discovery walks), not the launch subdir — `getProjectDir()` is only cwd.
|
|
let projectDir = cwd;
|
|
try {
|
|
projectDir = (await repo.root(cwd)) ?? cwd;
|
|
} catch {
|
|
projectDir = cwd;
|
|
}
|
|
const dirs = { projectDir, agentDir };
|
|
const initialDoc = await loadWatchdogConfigFile(await resolveAdvisorConfigEditPath(initialScope, dirs));
|
|
// Fullscreen editor on the alternate screen (the /settings idiom): the
|
|
// overlay holds the alt buffer + mouse tracking; the transcript stays put.
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
const done = () => {
|
|
overlayHandle?.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
// Label the seeded implicit-default row with the actual advisor-role model
|
|
// (NOT the first live advisor, which may be a named advisor from another scope).
|
|
const advisorRoleSel = resolveAdvisorRoleSelection(
|
|
this.ctx.settings,
|
|
this.ctx.session.modelRegistry.getAvailable(),
|
|
);
|
|
const defaultAdvisorModel = advisorRoleSel?.model;
|
|
const deps: AdvisorConfigDeps = {
|
|
modelRegistry: this.ctx.session.modelRegistry,
|
|
settings: this.ctx.settings,
|
|
scopedModels: this.ctx.session.scopedModels,
|
|
availableToolNames: this.ctx.session.getAdvisorAvailableToolNames(),
|
|
defaultModelLabel: defaultAdvisorModel
|
|
? `${defaultAdvisorModel.provider}/${defaultAdvisorModel.id}`
|
|
: undefined,
|
|
};
|
|
const overlay = new AdvisorConfigOverlayComponent(this.ctx.ui, deps, initialScope, initialDoc, {
|
|
loadDoc: async scope => loadWatchdogConfigFile(await resolveAdvisorConfigEditPath(scope, dirs)),
|
|
save: async (scope, doc) => {
|
|
await saveWatchdogConfigFile(await resolveAdvisorConfigEditPath(scope, dirs), doc);
|
|
// Re-discover the merged roster (project + user) so the live advisors
|
|
// reflect cross-level precedence, not just the edited file.
|
|
const discovered = await discoverAdvisorConfigs(cwd, agentDir);
|
|
const count = this.ctx.session.applyAdvisorConfigs(discovered.advisors, discovered.sharedInstructions);
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.showStatus(
|
|
count > 0
|
|
? `Saved ${scope} WATCHDOG.yml — ${count} advisor${count === 1 ? "" : "s"} active.`
|
|
: `Saved ${scope} WATCHDOG.yml. Run /advisor on to activate the configured advisors.`,
|
|
);
|
|
this.ctx.ui.requestRender();
|
|
},
|
|
close: done,
|
|
requestRender: () => this.ctx.ui.requestRender(),
|
|
notify: message => this.ctx.showStatus(message),
|
|
});
|
|
overlayHandle = this.ctx.ui.showOverlay(overlay, {
|
|
anchor: "bottom-center",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
fullscreen: true,
|
|
});
|
|
this.ctx.ui.setFocus(overlay);
|
|
this.ctx.ui.requestRender();
|
|
})();
|
|
}
|
|
|
|
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);
|
|
// Fullscreen dashboard on the alternate screen (the /settings idiom): the
|
|
// overlay borrows the terminal's alt buffer and enables mouse tracking for
|
|
// its lifetime, leaving the transcript untouched underneath.
|
|
const overlay = this.ctx.ui.showOverlay(dashboard, {
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
anchor: "top-left",
|
|
margin: 0,
|
|
fullscreen: true,
|
|
});
|
|
dashboard.onClose = () => {
|
|
overlay.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
dashboard.onRequestRender = () => {
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
const overlay = this.ctx.ui.showOverlay(dashboard, {
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
anchor: "top-left",
|
|
margin: 0,
|
|
});
|
|
dashboard.onClose = () => {
|
|
overlay.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
dashboard.onRequestRender = () => {
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 ConfiguredThinkingLevel, true);
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorBorderColor();
|
|
break;
|
|
case "personality":
|
|
void this.ctx.session.refreshBaseSystemPrompt().catch(err => {
|
|
this.ctx.showError(`Failed to apply personality: ${err}`);
|
|
});
|
|
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 "hideThinkingBlock":
|
|
this.ctx.hideThinkingBlock = value as boolean;
|
|
for (const child of this.ctx.chatContainer.children) {
|
|
if (child instanceof AssistantMessageComponent) {
|
|
child.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock);
|
|
}
|
|
}
|
|
// Full clear + replay so blocks frozen in committed scrollback on
|
|
// ED3-risk terminals retire their stale snapshots too (see
|
|
// InputController.toggleThinkingBlockVisibility).
|
|
this.ctx.ui.resetDisplay();
|
|
break;
|
|
case "proseOnlyThinking":
|
|
this.ctx.proseOnlyThinking = value as boolean;
|
|
for (const child of this.ctx.chatContainer.children) {
|
|
if (child instanceof AssistantMessageComponent) {
|
|
child.setProseOnlyThinking(value as boolean);
|
|
}
|
|
}
|
|
this.ctx.ui.resetDisplay();
|
|
break;
|
|
case "omitThinking":
|
|
this.ctx.session.agent.hideThinkingSummary = value as boolean;
|
|
break;
|
|
case "display.cacheMissMarker":
|
|
// Rebuild re-runs the usage-based detection under the new setting so
|
|
// markers appear/disappear; full reset retires any already committed
|
|
// to native scrollback (mirrors hideThinking).
|
|
this.ctx.rebuildChatFromMessages();
|
|
this.ctx.ui.resetDisplay();
|
|
break;
|
|
case "display.collapseCompacted":
|
|
// Rebuild swaps between the collapsed tail and the full inline
|
|
// history; full reset retires blocks already committed to native
|
|
// scrollback (mirrors cacheMissMarker).
|
|
this.ctx.rebuildChatFromMessages();
|
|
this.ctx.ui.resetDisplay();
|
|
break;
|
|
case "tui.tight":
|
|
setTuiTight(value as boolean);
|
|
this.ctx.ui.invalidate();
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
|
|
case "tui.renderMermaid":
|
|
setMarkdownMermaidRendering(value as boolean);
|
|
this.ctx.session.refreshBaseSystemPrompt().catch(err => {
|
|
this.ctx.showError(`Failed to apply Mermaid rendering setting: ${err}`);
|
|
});
|
|
this.ctx.rebuildChatFromMessages();
|
|
this.ctx.ui.resetDisplay();
|
|
break;
|
|
|
|
case "theme": {
|
|
setTheme(value as string, true).then(result => {
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.ui.requestRender();
|
|
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.ui.requestRender();
|
|
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 "git.enabled":
|
|
case "statusLinePreset":
|
|
case "statusLine.preset":
|
|
case "statusLineSeparator":
|
|
case "statusLine.separator":
|
|
case "statusLineShowHooks":
|
|
case "statusLine.showHookStatus":
|
|
case "statusLine.sessionAccent":
|
|
case "statusLine.transparent":
|
|
case "statusLine.compactThinkingLevel":
|
|
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"),
|
|
transparent: settings.get("statusLine.transparent"),
|
|
segmentOptions: settings.get("statusLine.segmentOptions"),
|
|
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
|
|
};
|
|
this.ctx.statusLine.updateSettings(statusLineSettings);
|
|
this.ctx.ui.requestRender();
|
|
break;
|
|
}
|
|
|
|
// Provider settings - update runtime preferences
|
|
case "providers.webSearch":
|
|
if (typeof value === "string" && isSearchProviderPreference(value)) {
|
|
setPreferredSearchProvider(value);
|
|
}
|
|
break;
|
|
case "providers.webSearchExclude":
|
|
if (Array.isArray(value)) {
|
|
setExcludedSearchProviders(value.filter(isSearchProviderId));
|
|
}
|
|
break;
|
|
case "providers.image":
|
|
if (isImageProviderPreference(value)) {
|
|
setPreferredImageProvider(value);
|
|
}
|
|
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 {
|
|
if (options?.temporaryOnly) {
|
|
this.#showModelPicker();
|
|
return;
|
|
}
|
|
this.#showModelHub({});
|
|
}
|
|
|
|
/**
|
|
* Compact session-only model picker (alt+p / `/switch`): a floating
|
|
* bottom-anchored overlay over the transcript. The current model is
|
|
* highlighted and preselected; a leading `@` searches ctrl+p quick roles.
|
|
*/
|
|
#showModelPicker(): void {
|
|
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
|
const current = this.ctx.session.model;
|
|
const quickRoleOrder = this.ctx.settings.get("cycleOrder");
|
|
const quickRoleCycle = this.ctx.session.getRoleModelCycle(quickRoleOrder);
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
let closed = false;
|
|
const done = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
overlayHandle?.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
const picker = new ModelPickerComponent(
|
|
this.ctx.ui,
|
|
this.ctx.settings,
|
|
this.ctx.session.modelRegistry,
|
|
this.ctx.session.scopedModels,
|
|
{
|
|
onPick: async (model, selector) => {
|
|
try {
|
|
// Session-only: update agent state but don't persist the model to settings.
|
|
await this.ctx.session.setModelTemporary(model);
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorBorderColor();
|
|
const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
|
|
this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`);
|
|
done();
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onPickRole: async entry => {
|
|
try {
|
|
await this.ctx.session.applyRoleModel(entry);
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorBorderColor();
|
|
this.ctx.showModelCycleTrack(
|
|
renderSegmentTrack(
|
|
quickRoleOrder.map(role => ({ label: role })),
|
|
quickRoleOrder.indexOf(entry.role),
|
|
),
|
|
);
|
|
done();
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onCancel: done,
|
|
},
|
|
{
|
|
currentContextTokens,
|
|
currentSelector: current ? `${current.provider}/${current.id}` : undefined,
|
|
quickRoles: quickRoleCycle?.models,
|
|
quickRoleOrder,
|
|
currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role,
|
|
},
|
|
);
|
|
overlayHandle = this.ctx.ui.showOverlay(picker, {
|
|
anchor: "bottom-center",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
});
|
|
this.ctx.ui.setFocus(picker);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
/**
|
|
* Fullscreen model hub on the alternate screen (the /settings idiom): the
|
|
* overlay enables mouse tracking for its lifetime and the transcript stays
|
|
* untouched underneath. `initialProviderId` preselects a provider's sidebar
|
|
* entry — used when reopening the hub after a /login round-trip.
|
|
*/
|
|
#showModelHub(hubOptions: { initialProviderId?: string }): void {
|
|
const currentContextTokens = this.ctx.session.getContextUsage()?.tokens ?? 0;
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
let hub: ModelHubComponent | undefined;
|
|
let closed = false;
|
|
const done = () => {
|
|
// Re-entrant guard: cancel paths (Esc, login forward) may race;
|
|
// the overlay must hide exactly once.
|
|
if (closed) return;
|
|
closed = true;
|
|
hub?.dispose();
|
|
overlayHandle?.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
hub = new ModelHubComponent(
|
|
this.ctx.ui,
|
|
this.ctx.settings,
|
|
this.ctx.session.modelRegistry,
|
|
this.ctx.session.scopedModels,
|
|
{
|
|
onAssign: async (model, role, thinkingLevel, selector) => {
|
|
// `auto` is session-global: never baked into a per-role model value
|
|
// (it can't round-trip through `model:<level>`). Apply it to the session
|
|
// separately and persist via `defaultThinkingLevel`.
|
|
const isAuto = thinkingLevel === AUTO_THINKING;
|
|
const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
|
|
const selectorValue = selector ?? `${model.provider}/${model.id}`;
|
|
try {
|
|
if (role === "default") {
|
|
const { switched } = await this.ctx.session.setModel(model, role, {
|
|
selector,
|
|
thinkingLevel: concreteThinking,
|
|
persist: true,
|
|
currentContextTokens,
|
|
});
|
|
if (isAuto) {
|
|
if (switched) {
|
|
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
|
|
} else {
|
|
this.ctx.settings.set("defaultThinkingLevel", AUTO_THINKING);
|
|
}
|
|
} else if (switched && concreteThinking && concreteThinking !== ThinkingLevel.Inherit) {
|
|
this.ctx.session.setThinkingLevel(concreteThinking);
|
|
}
|
|
if (switched) {
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.updateEditorBorderColor();
|
|
}
|
|
this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
|
|
} else {
|
|
// Other roles (smol, slow, custom): update settings, not the current model.
|
|
this.ctx.settings.setModelRole(role, formatModelSelectorValue(selectorValue, concreteThinking));
|
|
if (isAuto) {
|
|
this.ctx.session.setThinkingLevel(AUTO_THINKING, true);
|
|
}
|
|
const roleInfo = getRoleInfo(role, settings);
|
|
this.ctx.showStatus(`${roleInfo?.name ?? role} model: ${selector ?? model.id}`);
|
|
}
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onUnassign: role => {
|
|
try {
|
|
this.ctx.settings.setModelRole(role, undefined);
|
|
const roleInfo = getRoleInfo(role, settings);
|
|
this.ctx.showStatus(`${roleInfo?.name ?? role} role cleared — auto-selection applies`);
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onFallbackChainChange: (role, chain) => {
|
|
try {
|
|
const chains = { ...this.ctx.settings.get("retry.fallbackChains") };
|
|
if (chain.length === 0) {
|
|
delete chains[role];
|
|
} else {
|
|
chains[role] = chain;
|
|
}
|
|
this.ctx.settings.set("retry.fallbackChains", chains);
|
|
const roleInfo = getRoleInfo(role, settings);
|
|
this.ctx.showStatus(
|
|
chain.length > 0
|
|
? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
|
|
: `${roleInfo?.name ?? role} fallbacks cleared`,
|
|
);
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onLoginRequest: providerId => {
|
|
done();
|
|
void this.#loginThenReopenModelHub(providerId);
|
|
},
|
|
onCycleOrderChange: order => {
|
|
try {
|
|
this.ctx.settings.set("cycleOrder", order);
|
|
this.ctx.showStatus(
|
|
order.length > 0 ? `Quick-switch cycle: ${order.join(" → ")}` : "Quick-switch cycle cleared",
|
|
);
|
|
} catch (error) {
|
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
},
|
|
onCancel: () => done(),
|
|
},
|
|
{
|
|
initialProviderId: hubOptions.initialProviderId,
|
|
},
|
|
);
|
|
overlayHandle = this.ctx.ui.showOverlay(hub, {
|
|
anchor: "bottom-center",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
fullscreen: true,
|
|
});
|
|
this.ctx.ui.setFocus(hub);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
/** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */
|
|
async #loginThenReopenModelHub(providerId: string): Promise<void> {
|
|
const succeeded = await this.#handleOAuthLogin(providerId);
|
|
if (succeeded) {
|
|
this.#showModelHub({ initialProviderId: providerId });
|
|
}
|
|
}
|
|
|
|
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") {
|
|
// Show only installed plugins for 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;
|
|
}
|
|
|
|
// Install mode: show all available plugins from all marketplaces
|
|
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) {
|
|
// Hook cancelled the branch
|
|
done();
|
|
this.ctx.ui.requestRender();
|
|
return;
|
|
}
|
|
|
|
this.ctx.renderInitialMessages({ 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() };
|
|
});
|
|
}
|
|
|
|
showCopySelector(): void {
|
|
const targets = buildCopyTargets(this.ctx.session);
|
|
if (targets.length === 0) {
|
|
this.ctx.showStatus("Nothing to copy yet.");
|
|
return;
|
|
}
|
|
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
const done = () => {
|
|
overlayHandle?.hide();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
const selector = new CopySelectorComponent(targets, {
|
|
onPick: target => {
|
|
done();
|
|
if (target.content === undefined) return;
|
|
void copyToClipboard(target.content);
|
|
this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard");
|
|
},
|
|
onCancel: done,
|
|
});
|
|
|
|
overlayHandle = this.ctx.ui.showOverlay(selector, {
|
|
anchor: "bottom-center",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
});
|
|
this.ctx.ui.setFocus(selector);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
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 — rebuild the display transcript for the new leaf (the
|
|
// context from navigateTree is the LLM context, not the transcript).
|
|
this.ctx.renderInitialMessages({ 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.disposeChildren();
|
|
}
|
|
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(),
|
|
);
|
|
// Always open in current-folder scope; the empty-state hint in SessionList
|
|
// invites the user to Tab into all-projects rather than silently surfacing
|
|
// every project's history when the cwd has nothing to resume. See #3099.
|
|
const historyStorage = this.ctx.historyStorage;
|
|
const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined;
|
|
// Fullscreen session picker on the alternate screen (the /settings idiom):
|
|
// the overlay borrows the alt buffer and enables mouse tracking (wheel
|
|
// scroll + click-to-resume) for its lifetime, leaving the transcript
|
|
// untouched underneath. Anchored top-left at full size so a mouse row maps
|
|
// directly to a rendered line (the overlay paints from screen row 0), and
|
|
// `fillHeight` pads the body so the footer pins to the screen bottom.
|
|
let overlayHandle: OverlayHandle | undefined;
|
|
const done = () => {
|
|
overlayHandle?.hide();
|
|
this.focusActiveEditorArea();
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
const selector = new SessionSelectorComponent(
|
|
sessions,
|
|
async (session: SessionInfo) => {
|
|
done();
|
|
await this.handleResumeSession(session.path);
|
|
},
|
|
() => {
|
|
done();
|
|
},
|
|
() => {
|
|
// Release the alt buffer before teardown: shutdown() awaits flush/save/
|
|
// dispose/drain before stop() leaves the alt screen, so without this the
|
|
// fullscreen picker would freeze on screen for that window on Ctrl+C.
|
|
done();
|
|
void this.ctx.shutdown();
|
|
},
|
|
{
|
|
onDelete: 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,
|
|
});
|
|
}
|
|
},
|
|
historyMatcher,
|
|
loadAllSessions: () => SessionManager.listAll(),
|
|
getTerminalRows: () => this.ctx.ui.terminal.rows,
|
|
fillHeight: true,
|
|
},
|
|
);
|
|
selector.setOnRequestRender(() => this.ctx.ui.requestRender());
|
|
overlayHandle = this.ctx.ui.showOverlay(selector, {
|
|
anchor: "top-left",
|
|
width: "100%",
|
|
maxHeight: "100%",
|
|
margin: 0,
|
|
fullscreen: true,
|
|
});
|
|
this.ctx.ui.setFocus(selector);
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
|
|
#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.ctx.clearTransientSessionUi();
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.statusLine.resetActiveTime();
|
|
this.ctx.ui.requestRender();
|
|
this.ctx.updateEditorBorderColor();
|
|
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
|
await this.ctx.reloadTodos();
|
|
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
|
return true;
|
|
}
|
|
|
|
async handleResumeSession(sessionPath: string): Promise<void> {
|
|
this.ctx.clearTransientSessionUi();
|
|
|
|
const previousCwd = this.ctx.sessionManager.getCwd();
|
|
// Switch session via AgentSession (emits hook and tool session events). The
|
|
// SessionManager adopts the resumed session's own cwd when it differs.
|
|
await this.ctx.session.switchSession(sessionPath);
|
|
const newCwd = this.ctx.sessionManager.getCwd();
|
|
const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd);
|
|
if (movedProject) {
|
|
// Resumed a session from another project: re-point the process and every
|
|
// cwd-derived cache at it before rendering.
|
|
await this.ctx.applyCwdChange(newCwd);
|
|
}
|
|
this.#refreshSessionTerminalTitle();
|
|
this.ctx.updateEditorBorderColor();
|
|
|
|
// Clear and re-render the chat
|
|
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
|
|
await this.ctx.reloadTodos();
|
|
this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "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;
|
|
}
|
|
|
|
// Check if session file exists (may not exist for brand new sessions)
|
|
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;
|
|
}
|
|
|
|
// Delete the session file and artifacts directory
|
|
await storage.deleteSessionWithArtifacts(sessionFile);
|
|
|
|
// Show session selector
|
|
this.ctx.showStatus("Session deleted");
|
|
await this.showSessionSelector();
|
|
}
|
|
|
|
/**
|
|
* Run the OAuth login flow for `providerId` inside a cancellable
|
|
* {@link LoginDialogComponent} that replaces the editor slot. Esc aborts:
|
|
* the dialog's abort signal reaches the provider flow, any pending prompt
|
|
* rejects, and the editor is restored immediately. Returns true when
|
|
* credentials were stored.
|
|
*/
|
|
async #handleOAuthLogin(providerId: string): Promise<boolean> {
|
|
this.ctx.showStatus(`Logging in to ${providerId}…`);
|
|
const manualInput = this.ctx.oauthManualInput;
|
|
const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
|
|
let restored = false;
|
|
const restoreEditor = () => {
|
|
if (restored) return;
|
|
restored = true;
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
const dialog = new LoginDialogComponent(this.ctx.ui, providerId, (_success, message) => {
|
|
// Fires on Esc: unblock the editor immediately; the aborted flow's
|
|
// rejection settles the awaited login below.
|
|
restoreEditor();
|
|
if (message) this.ctx.showStatus(message);
|
|
});
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(dialog);
|
|
this.ctx.ui.setFocus(dialog);
|
|
this.ctx.ui.requestRender();
|
|
try {
|
|
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
|
signal: dialog.signal,
|
|
onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
|
|
// The dialog renders the full URL (SSH-safe copy target) and
|
|
// opens the browser best-effort.
|
|
dialog.showAuth(info.url, info.instructions, info.launchUrl);
|
|
if (useManualInput) {
|
|
dialog.showProgress(MANUAL_LOGIN_TIP);
|
|
}
|
|
},
|
|
onPrompt: (prompt: { message: string; placeholder?: string }) =>
|
|
dialog.showPrompt(prompt.message, prompt.placeholder),
|
|
onProgress: (message: string) => {
|
|
dialog.showProgress(message);
|
|
},
|
|
onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
|
|
});
|
|
this.ctx.session.modelRegistry.refreshInBackground();
|
|
const block = new TranscriptBlock();
|
|
block.addChild(
|
|
new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
|
|
);
|
|
block.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
|
|
this.ctx.present(block);
|
|
return true;
|
|
} catch (error: unknown) {
|
|
if (dialog.signal.aborted) {
|
|
// User-cancelled: the dialog already restored the editor and
|
|
// surfaced "Login cancelled".
|
|
return false;
|
|
}
|
|
this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
return false;
|
|
} finally {
|
|
if (useManualInput) {
|
|
manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
|
|
}
|
|
restoreEditor();
|
|
}
|
|
}
|
|
|
|
async #handleCredentialLogout(providerId: string, account: LogoutAccount): Promise<void> {
|
|
try {
|
|
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
|
const removed = await authStorage.removeCredential(providerId, account.credentialId);
|
|
if (!removed) {
|
|
this.ctx.showError(`Logout skipped: ${account.label} is no longer stored for ${providerId}.`);
|
|
return;
|
|
}
|
|
|
|
await this.ctx.session.modelRegistry.refresh();
|
|
const block = new TranscriptBlock();
|
|
block.addChild(
|
|
new Text(
|
|
theme.fg(
|
|
"success",
|
|
`${theme.status.success} Successfully logged out ${account.label} from ${providerId}`,
|
|
),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
block.addChild(new Text(theme.fg("dim", `Credential removed from ${getAgentDbPath()}`), 1, 0));
|
|
const remainingSource = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
|
|
if (remainingSource) {
|
|
block.addChild(
|
|
new Text(theme.fg("warning", `${providerId} is still authenticated via ${remainingSource}`), 1, 0),
|
|
);
|
|
}
|
|
this.ctx.present(block);
|
|
} catch (error: unknown) {
|
|
this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
async #showOAuthLogoutAccountSelector(providerId: string): Promise<void> {
|
|
const authStorage = this.ctx.session.modelRegistry.authStorage;
|
|
try {
|
|
await authStorage.reload();
|
|
} catch (error: unknown) {
|
|
this.ctx.showError(
|
|
`Could not load stored credentials: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
const provider = getOAuthProviders().find(candidate => candidate.id === providerId);
|
|
const accounts = toLogoutAccounts(providerId, authStorage.listStoredCredentials(providerId), {
|
|
activeIdentity: authStorage.getOAuthAccountIdentity(providerId, this.ctx.session.sessionId),
|
|
activeApiKey: authStorage.getCredentialOrigin(providerId)?.kind === "api_key",
|
|
});
|
|
if (accounts.length === 0) {
|
|
const source = authStorage.describeCredentialSource(providerId, this.ctx.session.sessionId);
|
|
const suffix = source ? ` Current auth comes from ${source}; remove that source to log out.` : "";
|
|
this.ctx.showError(`Logout skipped: no stored credentials for ${providerId}.${suffix}`);
|
|
return;
|
|
}
|
|
|
|
this.showSelector(done => {
|
|
const selector = new LogoutAccountSelectorComponent(
|
|
provider?.name ?? providerId,
|
|
accounts,
|
|
account => {
|
|
done();
|
|
void this.#handleCredentialLogout(providerId, account);
|
|
},
|
|
() => {
|
|
done();
|
|
this.ctx.ui.requestRender();
|
|
},
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
|
|
if (providerId) {
|
|
if (mode === "login") {
|
|
await this.#handleOAuthLogin(providerId);
|
|
} else {
|
|
await this.#showOAuthLogoutAccountSelector(providerId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mode === "logout") {
|
|
await this.#refreshOAuthProviderAuthState();
|
|
const oauthProviders = getOAuthProviders();
|
|
const loggedInProviders = oauthProviders.filter(provider =>
|
|
this.ctx.session.modelRegistry.authStorage.has(provider.id),
|
|
);
|
|
if (loggedInProviders.length === 0) {
|
|
this.ctx.showStatus("No stored provider credentials to log out. Remove env or config auth at its source.");
|
|
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.#showOAuthLogoutAccountSelector(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 };
|
|
});
|
|
}
|
|
|
|
async showResetUsageSelector(): Promise<void> {
|
|
const session = this.ctx.session;
|
|
this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
|
|
let statuses: ResetCreditAccountStatus[];
|
|
try {
|
|
statuses = await session.listResetCredits();
|
|
} catch (error) {
|
|
this.ctx.showError(`Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`);
|
|
return;
|
|
}
|
|
const accounts = toResetUsageAccounts(statuses);
|
|
if (accounts.length === 0) {
|
|
this.ctx.showStatus("No Codex accounts found. Use /login to add one.");
|
|
return;
|
|
}
|
|
if (!accounts.some(account => account.availableCount > 0)) {
|
|
this.ctx.showStatus(
|
|
accounts.some(account => account.error)
|
|
? "No saved resets available — some accounts couldn't be reached (try /login)."
|
|
: "No saved rate-limit resets available to spend right now.",
|
|
);
|
|
return;
|
|
}
|
|
this.showSelector(done => {
|
|
const selector = new ResetUsageSelectorComponent(
|
|
accounts,
|
|
account => {
|
|
done();
|
|
void this.#redeemReset(account);
|
|
},
|
|
() => {
|
|
done();
|
|
this.ctx.ui.requestRender();
|
|
},
|
|
);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
async #redeemReset(account: ResetUsageAccount): Promise<void> {
|
|
this.ctx.showStatus(`Spending 1 saved reset for ${account.label}…`, { dim: true });
|
|
let outcome: ResetCreditRedeemOutcome;
|
|
try {
|
|
outcome = await this.ctx.session.redeemResetCredit(account.target);
|
|
} catch (error) {
|
|
this.ctx.showError(
|
|
`Reset failed for ${account.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
const message = describeRedeemOutcome(outcome, account.label);
|
|
if (outcome.ok) {
|
|
this.ctx.showStatus(message);
|
|
// Refresh the status-line usage so the freshly-reset window shows.
|
|
this.ctx.statusLine.invalidate();
|
|
this.ctx.ui.requestRender();
|
|
} else {
|
|
this.ctx.showWarning(message);
|
|
}
|
|
}
|
|
|
|
async showDebugSelector(): Promise<void> {
|
|
const { DebugSelectorComponent } = await import("../../debug");
|
|
this.showSelector(done => {
|
|
const selector = new DebugSelectorComponent(this.ctx, done);
|
|
return { component: selector, focus: selector };
|
|
});
|
|
}
|
|
|
|
showAgentHub(observers: SessionObserverRegistry, options?: { requireContent?: boolean }): void {
|
|
const hubKeys = [
|
|
...this.ctx.keybindings.getKeys("app.agents.hub"),
|
|
...this.ctx.keybindings.getKeys("app.session.observe"),
|
|
];
|
|
let hub: AgentHubOverlayComponent | undefined;
|
|
|
|
// Render the hub inline in the editor slot — the same anchored region
|
|
// every other selector (model, session, tree, the `ask` tool) uses —
|
|
// rather than a floating overlay. A non-fullscreen overlay composited over
|
|
// a live transcript strands a stale copy in native scrollback every time a
|
|
// running subagent's progress grows the frame and scrolls the window; the
|
|
// hub is opened mid-run, so those copies stacked into a wall of duplicate
|
|
// "Agent Hub" frames bleeding the task tree behind them. As an editor-slot
|
|
// component it rides the normal append-only commit path: the transcript
|
|
// commits above it exactly once and the hub repaints in place.
|
|
const done = () => {
|
|
hub?.dispose();
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
|
|
hub = new AgentHubOverlayComponent({
|
|
observers,
|
|
hubKeys,
|
|
expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"),
|
|
onDone: done,
|
|
requestRender: () => this.ctx.ui.requestRender(),
|
|
registry: this.ctx.collabGuest?.agentRegistry,
|
|
remote: this.ctx.collabGuest?.hubRemote,
|
|
ui: this.ctx.ui,
|
|
getTool: name => this.ctx.session.getToolByName(name),
|
|
getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type),
|
|
cwd: this.ctx.sessionManager.getCwd(),
|
|
hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
|
|
proseOnlyThinking: () => this.ctx.proseOnlyThinking,
|
|
focusAgent: id => this.ctx.focusAgentSession(id),
|
|
sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
|
|
});
|
|
|
|
const showReadyHub = () => {
|
|
// The double-← gesture passes requireContent so it stays inert when
|
|
// neither live nor persisted subagents are available. Persisted rows now
|
|
// load asynchronously, so defer the gate until that scan has refreshed the
|
|
// hub instead of treating the initial empty table as authoritative.
|
|
if (options?.requireContent && hub.isEmpty) {
|
|
hub.dispose();
|
|
return;
|
|
}
|
|
|
|
this.ctx.editorContainer.clear();
|
|
this.ctx.editorContainer.addChild(hub);
|
|
this.ctx.ui.setFocus(hub);
|
|
this.ctx.ui.requestRender();
|
|
};
|
|
|
|
if (options?.requireContent && hub.isEmpty) {
|
|
void hub.persistedSubagentsReady.then(showReadyHub);
|
|
return;
|
|
}
|
|
|
|
showReadyHub();
|
|
}
|
|
}
|