import { type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction"; import type { ImageContent, Model, UsageReport } from "@oh-my-pi/pi-ai"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; import * as vcs from "@oh-my-pi/pi-natives/vcs"; import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui"; import { Loader, Spacer, Text } from "@oh-my-pi/pi-tui"; import { formatKeyHint } from "@oh-my-pi/pi-tui/app-keybindings"; import { appKey, editorKey } from "@oh-my-pi/pi-tui/chrome/keybinding-hints"; import { getAgentDbPath, getAgentDir, getProjectDir, logger, normalizePathForComparison, sanitizeText, } from "@oh-my-pi/pi-utils"; import { ADVISOR_DEFAULT_TOOL_NAMES, discoverAdvisorConfigs, loadWatchdogConfigFile, resolveAdvisorConfigEditPath, saveWatchdogConfigFile, } from "../../advisor"; import { reset as resetCapabilities } from "../../capability"; import type { AdvisorConfigScope } from "@oh-my-pi/pi-tui/overlays/advisor-config"; import { showGitOverlay } from "../../cli/git-tui"; import { formatLoginIdentity } from "../../cli/oauth-terminal"; import { acquireModelRoleMutation, applyModelPreset, formatModelPresetSwitch, isCleanModelPresetSwitch, modelPresetSavedMessage, saveModelPreset, } from "../../config/model-presets"; import { resolveAdvisorRoleSelection, resolveModelRoleValue } from "../../config/model-resolver"; import { formatModelSelectorValue } from "@oh-my-pi/pi-tui/overlays/model-selector"; import { getRoleInfo } from "../../config/model-roles"; import { settings } from "../../config/settings"; import { createSettingsHost } from "../../config/settings-ui"; import { createPluginSettingsHost } from "../../extensibility/plugins/settings-host"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { getInstalledPluginsRegistryPath, getMarketplacesCacheDir, getMarketplacesRegistryPath, getPluginsCacheDir, MarketplaceManager, } from "../../extensibility/plugins/marketplace"; import { getAvailableThemes, getSymbolTheme, previewTheme, theme } from "@oh-my-pi/pi-tui/theme"; import type { AgentHubOpenOptions, InteractiveModeContext, ResumeSessionOptions, SelectOnlyModelSelectorOptions, SessionPickHandler, } from "../../modes/types"; import type { SessionOAuthAccountList } from "../../session/agent-session-types"; import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage"; import { createForeignSessionStore, foreignSessionInfoToSessionInfo, foreignSessionSourceName, persistForeignSession, } from "../../session/foreign-session-import"; import type { ForeignSessionInfo, ForeignSessionSource } from "../../session/foreign-session-store"; import { previewModelCompactionPoint, setModelCompactionPoint } from "../../session/model-compaction-threshold"; import { isTranscriptEntry, type TranscriptEntry } from "../../session/session-context"; import { isUserRequestEntry } from "@oh-my-pi/pi-tui/chat/transcript-entry"; import type { TurnAct, TurnMessage } from "@oh-my-pi/pi-tui/chat/transcript-actions"; import type { SessionEntry, SessionTreeNode } from "../../session/session-entries"; import { findSessionContinuation, readSessionInfo, type SessionInfo } from "../../session/session-listing"; import { SessionManager } from "../../session/session-manager"; import { loadPinnedSessionIds, setSessionPinned } from "../../session/session-pins"; import { FileSessionStorage } from "../../session/session-storage"; import { listLogoutAccounts, logoutCredential } from "../../slash-commands/helpers/logout"; import type { LogoutAccount } from "@oh-my-pi/pi-tui/overlays/logout-account-selector"; import { describeRedeemOutcome, toResetUsageAccounts } from "../../slash-commands/helpers/reset-usage"; import { toSessionPinAccounts } from "../../slash-commands/helpers/session-pin"; import { accountIdentityLabel, collectStoredAccounts, collectUnreportedAccounts, selectReportableAccounts, } from "../../slash-commands/helpers/usage-accounts"; import { loadDailyActivity } from "../../stats/activity-client"; import { AUTO_THINKING, type ConfiguredThinkingLevel, concreteThinkingLevel, parseConfiguredThinkingLevel, } from "@oh-my-pi/pi-tui/thinking"; import type { ToolSession } from "../../tools"; import { AskTool } from "../../tools/ask"; import { type AskToolDetails } from "@oh-my-pi/pi-tui/tools/ask"; import { sanitizeDisplayWarnings, shortenPath } from "@oh-my-pi/pi-tui/render/render-utils"; import { sanitizeErrorLine } from "@oh-my-pi/pi-tui/chrome/error-block"; import { ToolAbortError } from "../../tools/tool-errors"; import { captureBrowserSession } from "../../utils/browser-session"; import { copyToClipboard } from "../../utils/clipboard"; import { openPath } from "../../utils/open"; import { setSessionTerminalTitle } from "../../utils/title-generator"; import { getAssistantMessageLinkTargets } from "@oh-my-pi/pi-tui/prompt/interactive-context-helpers"; import { type AdvisorConfigDeps, AdvisorConfigOverlayComponent } from "@oh-my-pi/pi-tui/overlays/advisor-config"; import { createAgentsHubDeps } from "../agents-hub-deps"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { collapseSharedUsageReports } from "@oh-my-pi/pi-tui/overlays/usage-display"; import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account"; import { AgentHubOverlayComponent } from "@oh-my-pi/pi-tui/overlays/agent-hub"; import { createAgentHubRuntime } from "../agent-hub-runtime"; import { AgentsHubComponent } from "@oh-my-pi/pi-tui/overlays/agents-hub"; import { CopySelectorComponent } from "@oh-my-pi/pi-tui/overlays/copy-selector"; import { ExtensionDashboard } from "@oh-my-pi/pi-tui/overlays/extensions/extension-dashboard"; import { listLiveToolRecords, liveToolRecordFromSession } from "@oh-my-pi/pi-tui/overlays/extensions/live-tool-session"; import { createExtensionDashboardRuntime } from "../components/extensions/dashboard-runtime"; import { HistorySearchComponent } from "@oh-my-pi/pi-tui/overlays/history-search"; import { HookEditorComponent } from "@oh-my-pi/pi-tui/overlays/hook-editor"; import { ModelHubComponent, type ModelRoleSelectionScope } from "@oh-my-pi/pi-tui/overlays/model-hub"; import { createModelBrowserSource } from "../model-browser-source"; import { formatSessionContinuationNotice } from "../persistence-failure"; import { ModelPickerComponent } from "@oh-my-pi/pi-tui/overlays/model-picker"; import { PluginSelectorComponent } from "@oh-my-pi/pi-tui/overlays/plugin-selector"; import { type ResetUsageAccount, ResetUsageSelectorComponent } from "@oh-my-pi/pi-tui/overlays/reset-usage-selector"; import { type BranchVariantPath, RewindSelectorComponent } from "@oh-my-pi/pi-tui/overlays/rewind-selector"; import { SessionAccountSelectorComponent } from "@oh-my-pi/pi-tui/overlays/session-account-selector"; import { SessionSelectorComponent, type SessionSelectorOptions } from "@oh-my-pi/pi-tui/overlays/session-selector"; import { SettingsSelectorComponent } from "@oh-my-pi/pi-tui/overlays/settings-selector"; import { TranscriptBlock } from "@oh-my-pi/pi-tui/chrome/transcript-container"; import { TreeSelectorComponent } from "@oh-my-pi/pi-tui/overlays/tree-selector"; import { ThinkingSelectorComponent } from "@oh-my-pi/pi-tui/overlays/thinking-selector"; import { UsageDashboardComponent } from "@oh-my-pi/pi-tui/overlays/usage-dashboard"; import { renderUsageReports } from "./command-controller"; import type { SessionObserverRegistry } from "@oh-my-pi/pi-tui/overlays/session-observer-registry"; import { cfgAdvisorSyncBacklog } from "../../advisor/settings"; import { cfgBranchSummaryEnabled } from "../../session/context-settings"; import { cfgCycleOrder, cfgDisabledProviders, cfgModelRoleStorage } from "../../config/model-settings"; import { cfgDefaultThinkingLevel, cfgRetryFallbackChains, cfgSessionWorktreeScope } from "../../session/settings"; import { cfgStatusLineCompactThinkingLevel, cfgStatusLineContextLine, cfgStatusLineLeftSegments, cfgStatusLinePreset, cfgStatusLineRightSegments, cfgStatusLineSegmentOptions, cfgStatusLineSeparator, cfgStatusLineSessionAccent, cfgStatusLineShowHookStatus, cfgStatusLineTransparent, cfgTreeFilterMode, } from "../settings"; import { cfgTaskAgentModelOverrides } from "../../task/settings"; /** First-use boundary for provider auth catalog and dialog components. */ async function loadProviderAuthUi() { const [{ PASTE_CODE_LOGIN_PROVIDERS }, oauth, loginDialog, logoutSelector, oauthSelector] = await Promise.all([ import("@oh-my-pi/pi-ai"), import("@oh-my-pi/pi-ai/oauth"), import("@oh-my-pi/pi-tui/overlays/login-dialog"), import("@oh-my-pi/pi-tui/overlays/logout-account-selector"), import("@oh-my-pi/pi-tui/overlays/oauth-selector"), ]); return { PASTE_CODE_LOGIN_PROVIDERS, getOAuthProviders: oauth.getOAuthProviders, getOAuthCredentialProvider: oauth.getOAuthCredentialProvider, LoginDialogComponent: loginDialog.LoginDialogComponent, LogoutAccountSelectorComponent: logoutSelector.LogoutAccountSelectorComponent, OAuthSelectorComponent: oauthSelector.OAuthSelectorComponent, }; } /** Menus that open at most once: a repeat request focuses the open one. */ type MenuKind = | "settings" | "model-picker" | "model-picker-select" | "model-hub" | "agents-dashboard" | "agent-hub" | "usage-dashboard"; /** An open menu: registered when the request runs, filled once its component mounts. */ interface OpenMenu { component?: Component; handle?: OverlayHandle; /** Shows `component` as a new overlay and focuses it; also raises a covered menu. */ mount?: () => OverlayHandle; } /** Return input to the current editor-slot owner after a fullscreen overlay closes. */ export function focusActiveEditorArea(ctx: InteractiveModeContext): void { ctx.ui.setFocus(ctx.editorContainer.children[0] ?? ctx.editor); } export class SelectorController { /** * Each single-instance menu from the moment it is requested until it closes, * so a repeat request (typed or clicked while the first is still loading, * or from a composer that stays reachable, as in Tern's native view) * focuses it instead of stacking another menu on top. */ #openMenus = new Map(); constructor(private ctx: InteractiveModeContext) {} /** * Mount a primary fullscreen menu through the one polished modal path shared * by Settings, Model Hub, and Agent Hub. */ #showFullscreenMenu(component: Component): OverlayHandle { const handle = this.ctx.ui.showOverlay(component, { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0, fullscreen: true, }); this.ctx.ui.setFocus(component); this.ctx.ui.requestRender(); return handle; } /** * Bring the open `kind` menu forward; true when one was open and the request * is handled. Overlays have no raise API, so a menu another overlay covers * is remounted on top rather than handed keys while hidden. */ #focusOpenMenu(kind: MenuKind): boolean { const open = this.#openMenus.get(kind); if (!open) return false; if (open.component && open.mount && this.#isCovered(open.component)) { open.handle?.hide(); open.handle = open.mount(); } else if (open.component) { this.ctx.ui.setFocus(open.component); } this.ctx.ui.requestRender(); return true; } /** Whether a visible overlay sits above `component`'s overlay. */ #isCovered(component: Component): boolean { const stack = this.ctx.ui.overlayStack; const at = stack.findIndex(entry => entry.component === component); return at >= 0 && stack.slice(at + 1).some(entry => !entry.hidden); } /** Mount `component` as `menu`'s overlay through `mount`, which also raises it later. */ #mountMenu(menu: OpenMenu, component: Component, mount: () => OverlayHandle): void { menu.component = component; menu.mount = mount; menu.handle = mount(); } /** Register a new `kind` menu; pass it to {@link #releaseMenu} when it closes or fails to open. */ #claimMenu(kind: MenuKind): OpenMenu { const menu: OpenMenu = {}; this.#openMenus.set(kind, menu); return menu; } #releaseMenu(kind: MenuKind, menu: OpenMenu): void { if (this.#openMenus.get(kind) === menu) this.#openMenus.delete(kind); } /** * Serialize default-role mutations with `/modelpreset switch`, which holds the * same shared tail in `config/model-presets.ts` for its whole apply. */ async #acquireDefaultRoleMutation(): Promise<() => void> { return acquireModelRoleMutation(); } async #refreshOAuthProviderAuthState(): Promise { const { getOAuthProviders } = await loadProviderAuthUi(); 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 { focusActiveEditorArea(this.ctx); } /** * Temporarily replaces the editor slot with a selector, restoring the prior * slot contents and focus when the selector finishes. */ showSelector(create: (done: () => void) => { component: Component; focus: Component }): void { const previousChildren = [...this.ctx.editorContainer.children]; const previousFocus = this.ctx.ui.getFocused(); const done = () => { this.ctx.editorContainer.clear(); for (const child of previousChildren) this.ctx.editorContainer.addChild(child); const focus = previousFocus && previousChildren.includes(previousFocus) ? previousFocus : (previousChildren[0] ?? this.ctx.editor); this.ctx.ui.setFocus(focus); }; 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 { if (this.#focusOpenMenu("settings")) return; const menu = this.#claimMenu("settings"); 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. const done = () => { menu.handle?.hide(); this.#releaseMenu("settings", menu); 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), ), settings: createSettingsHost(), plugins: createPluginSettingsHost(getProjectDir()), model: this.ctx.session.model, imageBudget: this.ctx.ui.imageBudget, requestRender: () => this.ctx.ui.requestRender(), composerPreviewStatus: this.ctx.statusLine, }, { 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: cfgStatusLinePreset.get(settings), leftSegments: cfgStatusLineLeftSegments.get(settings), rightSegments: cfgStatusLineRightSegments.get(settings), separator: cfgStatusLineSeparator.get(settings), showHookStatus: cfgStatusLineShowHookStatus.get(settings), sessionAccent: cfgStatusLineSessionAccent.get(settings), transparent: cfgStatusLineTransparent.get(settings), compactThinkingLevel: cfgStatusLineCompactThinkingLevel.get(settings), contextLine: cfgStatusLineContextLine.get(settings), segmentOptions: cfgStatusLineSegmentOptions.get(settings), ...previewSettings, }); this.ctx.ui.requestRender(); }, getStatusLinePreview: () => { // The bar exactly as the active composer shape renders it (box top // border, claude rule + chip, or the plain standalone bottom bar). const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns); return this.ctx.statusLine.getPreviewLines(availableWidth).join("\n"); }, describeStatusLinePreview: () => this.ctx.statusLine.describePreview(), onPluginsChanged: async () => { const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd()); clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined); await this.ctx.refreshSkillState(); await this.ctx.refreshSlashCommandState(); resetCapabilities(); this.ctx.ui.requestRender(); }, onCancel: () => { done(); // Restore status line to saved settings this.ctx.statusLine.updateSettings({ preset: cfgStatusLinePreset.get(settings), leftSegments: cfgStatusLineLeftSegments.get(settings), rightSegments: cfgStatusLineRightSegments.get(settings), separator: cfgStatusLineSeparator.get(settings), showHookStatus: cfgStatusLineShowHookStatus.get(settings), sessionAccent: cfgStatusLineSessionAccent.get(settings), transparent: cfgStatusLineTransparent.get(settings), compactThinkingLevel: cfgStatusLineCompactThinkingLevel.get(settings), contextLine: cfgStatusLineContextLine.get(settings), segmentOptions: cfgStatusLineSegmentOptions.get(settings), }); this.ctx.ui.requestRender(); }, }, ); this.#mountMenu(menu, selector, () => this.#showFullscreenMenu(selector)); }) .catch((error: unknown) => { // A menu that never opened must not block the next `/settings`. this.#releaseMenu("settings", menu); throw error; }); } /** * Fullscreen `/usage` dashboard on the alternate screen (the /settings * idiom): compact subscriptions grid + daily activity heatmap, with the * classic full report one keypress away. Takes no transcript space. */ showUsageDashboard(reports: UsageReport[]): void { // Checked here, not before the fetch: a repeat `/usage` whose fetch lands // while the first dashboard is open focuses it instead of stacking a copy. if (this.#focusOpenMenu("usage-dashboard")) return; const authStorage = this.ctx.session.modelRegistry.authStorage; const accounts = selectReportableAccounts( collectStoredAccounts(authStorage), provider => authStorage.usage.providerFor(provider) !== undefined, ); if (reports.length === 0 && accounts.length === 0) { this.ctx.showWarning("No usage data available."); return; } const unavailableAccounts = collectUnreportedAccounts(reports, accounts).map(account => ({ provider: account.provider, label: accountIdentityLabel(account), })); const currentProvider = this.ctx.session.model?.provider; const activeAccount = currentProvider ? this.ctx.session.modelRegistry.authStorage.oauth.identity(currentProvider, this.ctx.session.sessionId) : undefined; const usageModelSelectors = this.ctx.session.getUsageReportingModelSelectors(reports); const done = () => { menu.handle?.hide(); this.#releaseMenu("usage-dashboard", menu); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const dashboard = new UsageDashboardComponent({ reports, unavailableAccounts, renderDetail: (width, current) => renderUsageReports( current, theme, Date.now(), width, provider => (provider === currentProvider ? activeAccount : undefined), usageModelSelectors, unavailableAccounts, ), loadActivity: loadDailyActivity, refresh: () => this.ctx.session.fetchUsageReports(), requestRender: () => this.ctx.ui.requestRender(), onClose: done, }); const menu = this.#claimMenu("usage-dashboard"); this.#mountMenu(menu, dashboard, () => this.#showFullscreenMenu(dashboard)); } 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 = vcs.repo(cwd)?.root() ?? cwd; } catch { projectDir = cwd; } const dirs = { projectDir, agentDir }; const initialDoc = await loadWatchdogConfigFile(await resolveAdvisorConfigEditPath(initialScope, dirs)); if (initialDoc.warnings?.length) { this.ctx.showWarning(`WATCHDOG.yml: ${sanitizeDisplayWarnings(initialDoc.warnings).join("; ")}`); } // Fullscreen editor on the alternate screen (the /settings idiom): the // overlay holds the alt buffer + mouse tracking; the transcript stays put. 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 = { getAvailableModels: () => this.ctx.session.modelRegistry.getAvailable(), browserSource: createModelBrowserSource(this.ctx.settings, model => this.ctx.session.effectiveServiceTier(model), ), defaultToolNames: ADVISOR_DEFAULT_TOOL_NAMES, externalEditor: text => { const command = getEditorCommand(); return command ? openInEditor(command, text) : Promise.resolve(null); }, scopedModels: this.ctx.session.scopedModels, availableToolNames: this.ctx.session.getAdvisorAvailableToolNames(), syncBacklog: cfgAdvisorSyncBacklog.get(this.ctx.settings), 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, discovered.sharedMaxNotesPerUpdate, ); this.ctx.statusLine.invalidate(); if (discovered.warnings.length > 0) { this.ctx.showWarning(`WATCHDOG.yml: ${sanitizeDisplayWarnings(discovered.warnings).join("; ")}`); } 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), // Scope switches happen inside the overlay; the initial file's warnings // were already shown above, so only newly activated files arrive here. warn: message => this.ctx.showWarning(message), getAdvisorStats: () => this.ctx.session.getAdvisorStats().advisors, getUsageReports: async () => { const reports = await this.ctx.session.fetchUsageReports?.(); return reports ? collapseSharedUsageReports(reports) : null; }, getQuotaLimitFilter: (provider, sessionId) => { const identity = this.ctx.session.modelRegistry.authStorage.oauth.identity( provider, sessionId ?? this.ctx.session.sessionId, ); return identity ? (report, limit) => limitMatchesActiveAccount(report, limit, identity) : undefined; }, }); const 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; // Ctrl+R also opens over hook/ask prompts that replace the composer in the // editor slot; the pick belongs to that prompt, not the hidden composer. const focused = this.ctx.ui.getFocused(); const target = focused instanceof HookEditorComponent ? focused : this.ctx.editor; this.showSelector(done => { const component = new HistorySearchComponent( historyStorage, prompt => { done(); target.setText(prompt); this.ctx.ui.requestRender(); }, () => { done(); this.ctx.ui.requestRender(); }, ); component.setOnRequestRender(() => 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 { const dashboard = await ExtensionDashboard.create({ runtime: createExtensionDashboardRuntime({ cwd: getProjectDir(), settings: this.ctx.settings, extensionRoots: () => this.ctx.session.effectiveExtensionRoots, mcpManager: this.ctx.mcpManager, eventBus: this.ctx.eventBus, onMcpToolsChanged: tools => this.ctx.session.refreshMCPTools(tools), browserMcpFilterEnabled: () => this.ctx.session.getEvalPreludes().some(definition => definition.name === "browser"), }), terminalHeight: this.ctx.ui.terminal.rows, toolSource: { getLiveTool: name => liveToolRecordFromSession(this.ctx.session, name), listLiveTools: () => listLiveToolRecords(this.ctx.session), }, }); // 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 = () => { dashboard.dispose(); overlay.hide(); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; dashboard.onRequestRender = () => { this.ctx.ui.requestRender(); }; } /** * Fullscreen git UI on the alternate screen (the /models idiom): split * diff viewer, staging sidebar, and commit composer. Resolves focus back * to the editor when the user closes it. */ async showGitTui(revision?: string): Promise { try { await showGitOverlay(this.ctx.ui, { cwd: getProjectDir(), revision }); } catch (error) { this.ctx.showStatus(error instanceof Error ? error.message : String(error)); } this.focusActiveEditorArea(); this.ctx.ui.requestRender(); } /** * Fullscreen agents hub on the alternate screen (the /models idiom): scope * sidebar, agent rows, and chip strips that dive into the model browser. */ async showAgentsDashboard(): Promise { if (this.#focusOpenMenu("agents-dashboard")) return; const menu = this.#claimMenu("agents-dashboard"); const activeModel = this.ctx.session.model; const activeModelPattern = activeModel ? `${activeModel.provider}/${activeModel.id}` : undefined; const defaultModelPattern = this.ctx.settings.getModelRole("default"); let closed = false; const done = () => { if (closed) return; closed = true; hub?.dispose(); menu.handle?.hide(); this.#releaseMenu("agents-dashboard", menu); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; let hub: AgentsHubComponent; try { hub = await AgentsHubComponent.create( this.ctx.ui, createAgentsHubDeps( getProjectDir(), this.ctx.settings, this.ctx.session.modelRegistry, () => this.ctx.session.effectiveExtensionRoots, activeModelPattern, defaultModelPattern, model => this.ctx.session.effectiveServiceTier(model), ), { onCancel: () => done() }, ); } catch (error) { // A dashboard that never opened must not block the next `/agents`. this.#releaseMenu("agents-dashboard", menu); throw error; } this.#mountMenu(menu, hub, () => this.#showFullscreenMenu(hub)); } /** * Apply the side effects of a setting change the user made in this process * (settings selector, approved `cfg://` writes); the value is already stored. * Only `defaultThinkingLevel` needs this: it also switches the live session, * which config reloads and parent-session writes must not do. Every other * setting applies through handle listeners owned by the session and InteractiveMode. */ handleSettingChange(id: string, value: unknown): void { if (id !== cfgDefaultThinkingLevel.id || typeof value !== "string") return; const level = parseConfiguredThinkingLevel(value); if (level === undefined || level === this.ctx.session.configuredThinkingLevel()) return; this.ctx.session.setThinkingLevel(level); } /** * Open a model selector. A focused subagent always gets the session-only * picker: the hub assigns persistent roles, which configure the main session * and future spawns rather than the agent being viewed. */ showModelSelector(options?: { temporaryOnly?: boolean; selectOnly?: SelectOnlyModelSelectorOptions }): void { if (options?.selectOnly) { this.#showModelPicker({ selectOnly: options.selectOnly }); return; } if (options?.temporaryOnly || this.ctx.focusedAgentId) { this.#showModelPicker(); return; } this.#showModelHub({}); } /** * Session-only model switch (`/switch `) for the viewed session: * applies the resolved model without persisting it. Compacts first when the * transcript exceeds the target's context window, mirroring an over-context * pick in the alt+p picker. Failures surface as status errors. */ async switchSessionModel(model: Model, thinkingLevel?: ConfiguredThinkingLevel): Promise { const contextTokens = this.ctx.viewSession.getContextUsage()?.tokens ?? 0; const contextWindow = model.contextWindow ?? 0; const overContext = contextWindow > 0 && contextTokens > contextWindow; try { await this.#applySessionModel(model, `${model.provider}/${model.id}`, thinkingLevel, overContext); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } } /** * Apply a session-only model to the viewed session: update agent state but * never persist to settings. `compactFirst` runs compaction with the current * model before switching (the target cannot fit the transcript); the switch * runs in the before-flush hook so any prompt queued during compaction * executes on the target model, and the idempotent post-return call covers * the early "nothing to compact" return that skips the hook. A cancelled or * failed compaction keeps the current model. A focused subagent switches * directly: `/compact` drives the main session, and the agent's own context * maintenance recovers an oversized transcript on its next turn. */ async #applySessionModel( model: Model, selector: string, thinkingLevel: ConfiguredThinkingLevel | undefined, compactFirst: boolean, ): Promise { const target = this.ctx.viewSession; const focusedAgentId = this.ctx.focusedAgentId; const apply = async () => { const level = thinkingLevel ?? target.resolveTemporaryModelThinkingLevel(model); await target.setModelTemporary(model, level); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); if (focusedAgentId) { this.ctx.showStatus(`Agent ${focusedAgentId} model: ${selector}`); return; } const roleSelectorHint = appKey(this.ctx.keybindings, "app.model.select") || formatKeyHint("alt+m"); this.ctx.showStatus(`Session-only model: ${selector}. Use ${roleSelectorHint} or /model for roles.`); }; if (!compactFirst || focusedAgentId) { await apply(); return; } let switched = false; const switchAfterCompaction = async (outcome: CompactionOutcome) => { if (switched || outcome !== "ok") return; switched = true; await apply(); }; // The target reads what this pass writes: no provider-native method it cannot replay. const outcome = await this.ctx.handleCompactCommand(undefined, undefined, switchAfterCompaction, { readerModel: model, }); await switchAfterCompaction(outcome); } /** * 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(options?: { selectOnly?: SelectOnlyModelSelectorOptions }): void { const selectOnly = options?.selectOnly; const menuKind = selectOnly ? "model-picker-select" : "model-picker"; const closeOpenMenu = (kind: MenuKind) => { const open = this.#openMenus.get(kind); if (!open) return; open.handle?.hide(); this.#releaseMenu(kind, open); }; if (selectOnly) { closeOpenMenu("model-picker"); closeOpenMenu("model-picker-select"); } else { closeOpenMenu("model-picker-select"); if (this.#focusOpenMenu("model-picker")) return; } const viewed = this.ctx.viewSession; const currentContextTokens = viewed.getContextUsage()?.tokens ?? 0; const current = viewed.model; const quickRoleOrder = cfgCycleOrder.get(this.ctx.settings); const quickRoleCycle = viewed.getRoleModelCycle(quickRoleOrder); const currentSelector = selectOnly ? selectOnly.currentSelector : current ? `${current.provider}/${current.id}` : undefined; // Preselect the effective Task model in task mode: the configured override, // else the session model (the bundled task agent inherits it by default). const taskOverride = cfgTaskAgentModelOverrides.get(this.ctx.settings).task; const taskSelector = (Array.isArray(taskOverride) ? taskOverride[0] : taskOverride) ?? currentSelector; const menu = this.#claimMenu(menuKind); const done = () => { if (this.#openMenus.get(menuKind) !== menu) return; menu.handle?.hide(); this.#releaseMenu(menuKind, menu); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const isCurrent = () => this.#openMenus.get(menuKind) === menu; const picker = new ModelPickerComponent( this.ctx.ui, createModelBrowserSource(this.ctx.settings, model => viewed.effectiveServiceTier(model)), this.ctx.session.modelRegistry, this.ctx.session.scopedModels, { onPick: async (model, selector, { overContext }) => { if (!isCurrent()) return; if (selectOnly) { done(); try { await selectOnly.onSelect(selector); } catch (error) { this.ctx.showError(sanitizeErrorLine(error)); } return; } try { // Over-context pick: close the picker first so the compaction // loader is visible. if (overContext) done(); await this.#applySessionModel(model, selector, undefined, overContext); if (!overContext) done(); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, onPickRole: async entry => { if (!isCurrent() || selectOnly) return; try { await this.ctx.viewSession.applyRoleModel(entry); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); this.ctx.showModelCycleTrack( quickRoleOrder.map(role => ({ label: role })), quickRoleOrder.indexOf(entry.role), ); done(); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, onPickTask: (_model, selector) => { if (!isCurrent() || selectOnly) return; // Session-only: layer the Task override onto the runtime settings // layer so it is never persisted, mirroring the session-model pick. cfgTaskAgentModelOverrides.override(this.ctx.settings, { ...cfgTaskAgentModelOverrides.get(this.ctx.settings), task: selector, }); this.ctx.showStatus(`Task subagent model (session-only): ${selector}. Use /agents to persist.`); done(); }, onCancel: done, }, { currentContextTokens, currentSelector, taskModeKeys: this.ctx.keybindings.getKeys("app.model.selectTemporary"), taskSelector, quickRoles: quickRoleCycle?.models, quickRoleOrder, currentQuickRole: quickRoleCycle?.models[quickRoleCycle.currentIndex]?.role, selectOnly: selectOnly !== undefined, }, ); this.#mountMenu(menu, picker, () => { const handle = this.ctx.ui.showOverlay(picker, { anchor: "bottom-center", width: "100%", maxHeight: "100%", margin: 0, }); this.ctx.ui.setFocus(picker); return handle; }); 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 { if (this.#focusOpenMenu("model-hub")) return; 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(); menu.handle?.hide(); this.#releaseMenu("model-hub", menu); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const hub = new ModelHubComponent( this.ctx.ui, createModelBrowserSource(this.ctx.settings, model => this.ctx.session.effectiveServiceTier(model)), this.ctx.session.modelRegistry, this.ctx.session.scopedModels, { onAssign: async (model, role, thinkingLevel, selector, scope?: ModelRoleSelectionScope) => { const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined; const configuredStorage = cfgModelRoleStorage.get(this.ctx.settings); const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global"; const selectorValue = selector ?? `${model.provider}/${model.id}`; const scopeLabel = configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : ""; const defaultStatusLabel = configuredStorage === "project" ? `${scopeLabel}default` : "Default"; try { if (role === "default") { // `auto` on the default role configures the active session. Other roles // persist an explicit `:auto` suffix and must not mutate the current model. const isAuto = thinkingLevel === AUTO_THINKING; const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const effectiveProvenance = this.ctx.settings.getModelRoleProvenance("default"); const shadowedGlobal = configuredStorage === "project" && targetScope === "global" && (effectiveProvenance === "project" || effectiveProvenance === "overlay" || (effectiveProvenance === "runtime" && this.ctx.settings.isProjectModelRoleRuntimeOverrideActive("default"))); const shadowedProject = configuredStorage === "project" && targetScope === "project" && effectiveProvenance === "overlay"; if (shadowedGlobal) { this.ctx.settings.setModelRole( "default", formatModelSelectorValue(selectorValue, concreteThinking), ); if (isAuto) { cfgDefaultThinkingLevel.set(this.ctx.settings, AUTO_THINKING); } } else if (shadowedProject) { this.ctx.settings.setProjectModelRole( "default", formatModelSelectorValue(selectorValue, concreteThinking), ); if (isAuto) { cfgDefaultThinkingLevel.set(this.ctx.settings, AUTO_THINKING); } } else { const { switched } = await this.ctx.session.setModel(model, role, { selector, thinkingLevel: isAuto ? ThinkingLevel.Inherit : concreteThinking, persist: targetScope === "global", }); if (!switched) return false; if (targetScope === "project") { this.ctx.settings.setProjectModelRole( "default", formatModelSelectorValue(selectorValue, concreteThinking), ); } 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(`${defaultStatusLabel} model: ${selector ?? model.id}`); } else { // Other roles (smol, slow, custom): update settings, not the current model. const modelRoleValue = formatModelSelectorValue(selectorValue, thinkingLevel); if (targetScope === "project") { this.ctx.settings.setProjectModelRole(role, modelRoleValue); } else { this.ctx.settings.setModelRole(role, modelRoleValue); } const roleInfo = getRoleInfo(role, settings); this.ctx.showStatus( `${scopeLabel}${roleInfo?.tag ?? roleInfo?.name ?? role} model: ${selector ?? model.id}`, ); } return true; } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); return false; } finally { releaseDefaultMutation?.(); hub?.refreshAfterExternalMutation(); } }, onUnassign: async (role, scope?: ModelRoleSelectionScope) => { const releaseDefaultMutation = role === "default" ? await this.#acquireDefaultRoleMutation() : undefined; const configuredStorage = cfgModelRoleStorage.get(this.ctx.settings); const targetScope = configuredStorage === "project" ? (scope ?? "project") : "global"; const scopeLabel = configuredStorage === "project" ? `${targetScope === "project" ? "Project" : "Global"} ` : ""; try { const previousEffectiveRoleValue = role === "default" ? this.ctx.settings.getModelRole("default") : undefined; if (targetScope === "project") { this.ctx.settings.clearProjectModelRole(role); } else { this.ctx.settings.setModelRole(role, undefined); } const roleInfo = getRoleInfo(role, settings); this.ctx.showStatus( `${scopeLabel}${roleInfo?.tag ?? roleInfo?.name ?? role} role cleared — auto-selection applies`, ); // Clearing either persisted scope can also remove a captured // runtime override. When that changes the effective default, or // the live session runs a different model than it (an earlier // in-session pick that a project role shadows), resolve the // exposed persisted layer and switch the live session without // writing it back to global settings. Overlay and runtime // provenance remain authoritative and session-neutral. if (role === "default") { const fallbackRoleValue = this.ctx.settings.getModelRole("default"); const fallbackProvenance = this.ctx.settings.getModelRoleProvenance("default"); const exposesPersistedFallback = fallbackProvenance === "project" || fallbackProvenance === "global"; if (fallbackRoleValue && exposesPersistedFallback) { const scopedModels = this.ctx.session.scopedModels.map(sm => sm.model); const availableModels = scopedModels.length > 0 ? scopedModels : this.ctx.session.getAvailableModels(); const resolved = resolveModelRoleValue(fallbackRoleValue, availableModels, { settings: this.ctx.settings, }); const live = this.ctx.session.model; const liveDiffers = !live || !resolved.model || live.provider !== resolved.model.provider || live.id !== resolved.model.id; if (resolved.model && (fallbackRoleValue !== previousEffectiveRoleValue || liveDiffers)) { const fallbackModel = resolved.model; const isAuto = resolved.thinkingLevel === AUTO_THINKING; let concreteThinking = concreteThinkingLevel(resolved.thinkingLevel); let isAutoFromDefault = false; if (!resolved.explicitThinkingLevel && !concreteThinking) { const defaultLevel = parseConfiguredThinkingLevel( cfgDefaultThinkingLevel.get(this.ctx.settings), ); if (defaultLevel === AUTO_THINKING) { isAutoFromDefault = true; } else if (defaultLevel) { concreteThinking = defaultLevel; } } const effectiveIsAuto = isAuto || isAutoFromDefault; const { switched } = await this.ctx.session.setModel(fallbackModel, "default", { persist: false, thinkingLevel: effectiveIsAuto ? ThinkingLevel.Inherit : (concreteThinking ?? ThinkingLevel.Inherit), }); if (!switched) return; if (effectiveIsAuto) { 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: ${fallbackModel.provider}/${fallbackModel.id}`); } } } } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } finally { releaseDefaultMutation?.(); hub?.refreshAfterExternalMutation(); } }, onFallbackChainChange: (role, chain) => { try { cfgRetryFallbackChains.setEntry(this.ctx.settings, role, chain.length > 0 ? chain : undefined); const roleInfo = getRoleInfo(role, settings); this.ctx.showStatus( chain.length > 0 ? `${roleInfo?.tag ?? roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}` : `${roleInfo?.tag ?? roleInfo?.name ?? role} fallbacks cleared`, ); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, onLoginRequest: providerId => { done(); void this.#loginThenReopenModelHub(providerId); }, onCatalogRefreshed: async () => { await this.ctx.session.refreshScopedModels(); return this.ctx.session.scopedModels; }, onCycleOrderChange: order => { try { cfgCycleOrder.set(this.ctx.settings, 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)); } }, onCompactionPointChange: (model, input, confirmed) => { try { const update = setModelCompactionPoint(this.ctx.settings, model, input, { tiers: this.ctx.session.modelRegistry.contextWindowTiers(model), confirmed, }); if (update.kind === "confirm") return update; const { described, summary } = update; this.ctx.showStatus( `Compaction limit for ${model.provider}/${model.id}: ${described}${summary ? ` · ${summary}` : ""}`, ); this.ctx.statusLine.invalidate(); // The entry can move the model between window tiers; the open hub's // rows are a pre-rebuild snapshot until the catalog rebuild settles. // The settings listener's own reapply coalesces onto this one. void this.ctx.session.modelRegistry .reapplyModelPolicies() .then(() => { if (!closed) hub.refreshAfterExternalMutation(); }) .catch(error => logger.warn("model hub refresh after compaction point failed", { error: String(error) }), ); return undefined; } catch (error) { return { kind: "error", message: error instanceof Error ? error.message : String(error) }; } }, previewCompactionPoint: (model, input) => previewModelCompactionPoint( this.ctx.settings, model, input, this.ctx.session.modelRegistry.contextWindowTiers(model), ), onSavePreset: name => { try { saveModelPreset(this.ctx.settings, name); this.ctx.showStatus(modelPresetSavedMessage(this.ctx.settings, name)); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, onSwitchPreset: async name => { try { const result = await applyModelPreset(this.ctx.settings, this.ctx.session, name); const message = formatModelPresetSwitch(name, result); if (result.kind === "switched") { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); } if (isCleanModelPresetSwitch(result)) this.ctx.showStatus(message); else this.ctx.showWarning(message); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } finally { hub?.refreshAfterExternalMutation(); } }, onCancel: () => done(), }, { initialProviderId: hubOptions.initialProviderId, currentSelector: this.ctx.session.model ? `${this.ctx.session.model.provider}/${this.ctx.session.model.id}` : undefined, }, ); const menu = this.#claimMenu("model-hub"); this.#mountMenu(menu, hub, () => this.#showFullscreenMenu(hub)); } /** /login round-trip for a locked provider; reopen the hub on that provider only after a successful login. */ async #loginThenReopenModelHub(providerId: string): Promise { const succeeded = await this.#handleOAuthLogin(providerId); if (succeeded) { this.#showModelHub({ initialProviderId: providerId }); } } async showPluginSelector(mode: "install" | "uninstall" = "install"): Promise { 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() }; }); } /** The rewind selector (Esc Esc), its outline on the latest turn, or on entry `startAt` (a turn's Rewind to here…). */ showUserMessageSelector(startAt?: string): void { const entries = this.ctx.sessionManager.getBranch().filter(isTranscriptEntry); if (entries.length === 0) { this.ctx.showStatus("No messages to branch from"); return; } const done = () => { overlayHandle?.hide(); selector?.dispose(); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const selector = new RewindSelectorComponent(entries, { ui: this.ctx.ui, getTool: name => this.ctx.session.getToolByName(name), isBuiltInTool: name => this.ctx.session.hasBuiltInTool(name), getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type), cwd: this.ctx.sessionManager.getCwd(), hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock, proseOnlyThinking: () => this.ctx.proseOnlyThinking, expandThinkingBlocks: () => this.ctx.expandThinkingBlocks, linkTargets: getAssistantMessageLinkTargets(this.ctx), requestRender: () => this.ctx.ui.requestRender(), siblingPaths: entryId => this.#siblingBranchPaths(entryId), startAt, onSelect: entryId => void this.#rewindFromTranscript(entryId, done), onCancel: done, }); if (selector.targetCount === 0) { selector.dispose(); this.ctx.showStatus("No messages to branch from"); return; } // Fullscreen alternate-screen overlay: the transcript replica draws over // the live one, and the normal screen stays untouched until the rewind // itself rewrites it. const 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(); } /** * Alternate branches of `entryId`'s turn for the rewind selector's strip: * every other child of its parent, each unrolled along its most-recent * descendants (children are timestamp-ordered, so the last child chain is * the branch's latest continuation) and filtered to message entries. */ #siblingBranchPaths(entryId: string): BranchVariantPath[] { const entry = this.ctx.sessionManager.getEntry(entryId); if (!entry) return []; const forest = this.ctx.sessionManager.getTree(); const byId = new Map(); const stack = [...forest]; while (stack.length > 0) { const node = stack.pop()!; byId.set(node.entry.id, node); stack.push(...node.children); } const siblings = entry.parentId === null ? forest.filter(node => node.entry.id !== entryId) : (byId.get(entry.parentId)?.children ?? []).filter(node => node.entry.id !== entryId); const paths: BranchVariantPath[] = []; for (const sibling of siblings) { const entries: TranscriptEntry[] = []; let node: SessionTreeNode | undefined = sibling; while (node) { if (isTranscriptEntry(node.entry)) entries.push(node.entry); node = node.children.at(-1); } if (entries.length > 0) paths.push({ rootId: sibling.entry.id, entries }); } return paths; } /** * Complete an esc-esc rewind in place via `navigateTree`: the session tree * keeps the old path as a sibling branch instead of forking a child * session. A user-request target (plain prompt, user-invoked skill/collab * prompt) rewinds PAST itself (leaf moves to its parent) and its draft * replaces the editor text, so it is a real move even when it is the * current leaf; every other target lands the leaf on the entry. `done` * closes the fullscreen selector after the transcript is rebuilt so the * alternate screen never flashes a stale transcript. `resend` sends a user * target's prompt again at once (a turn's Retry) instead of leaving it in * the editor; otherwise the status toast offers Undo ({@link #undoRewind}). */ async #rewindFromTranscript(entryId: string, done: () => void, resend = false): Promise { const entry = this.ctx.sessionManager.getEntry(entryId); if (!entry || !isTranscriptEntry(entry)) { done(); return; } const isUserTarget = isUserRequestEntry(entry); const realLeafId = this.ctx.sessionManager.getLeafId(); if (entryId === realLeafId && !isUserTarget) { done(); this.ctx.showStatus("Already at this point"); return; } const treeRewind = this.#treeRewindBoundary(entryId, realLeafId); const branch = this.ctx.sessionManager.getBranch(); const at = branch.findIndex(e => e.id === entryId); const dropped = at < 0 ? 0 : branch.slice(isUserTarget ? at : at + 1).filter(e => isUserRequestEntry(e)).length; const draft = { text: this.ctx.editor.getText(), images: [...this.ctx.editor.pendingImages] }; try { const result = await this.ctx.session.navigateTree(entryId, { summarize: false }); if (result.cancelled) { done(); this.ctx.showStatus("Navigation cancelled"); return; } const fastRewind = treeRewind !== undefined && this.ctx.sessionManager.getLeafId() === treeRewind.expectedLeafId && this.ctx.truncateTranscriptFromMessage(treeRewind.message); if (!fastRewind) { await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); } await this.ctx.reloadTodos(); if (result.editorText && (isUserTarget || !this.ctx.editor.getText().trim())) { this.ctx.editor.setDraft(result.editorText, result.editorImages); if (resend && isUserTarget) this.ctx.editor.submit(); } done(); if (resend) { this.ctx.showStatus("Retrying the turn"); return; } this.ctx.showStatus( dropped > 0 ? `Rewound ${dropped} turn${dropped === 1 ? "" : "s"}` : "Rewound to selected point", { action: { act: "undo", label: "Undo", run: () => void this.#undoRewind(realLeafId, isUserTarget ? draft : undefined), }, }, ); } catch (error) { done(); this.ctx.showError(error instanceof Error ? error.message : String(error)); } } /** * Undo of a transcript rewind: the leaf goes back to `leafId`, where it was (the rewind kept that path as a * branch), and `draft`, what the editor held before the rewind put a prompt there, comes back. */ async #undoRewind( leafId: string | null, draft: { text: string; images: ImageContent[] } | undefined, ): Promise { if (!leafId) return; try { const result = await this.ctx.session.navigateTree(leafId, { summarize: false }); if (result.cancelled) { this.ctx.showStatus("Navigation cancelled"); return; } await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (draft) this.ctx.editor.setDraft(draft.text, draft.images); this.ctx.showStatus("Rewind undone"); } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } } /** * A transcript turn's hold-menu action on `message`, found on the current branch by role and timestamp: * `rewind` opens the rewind selector on it; `edit` rewinds past the turn's prompt and puts it in the editor, * as Esc Esc does; `retry` rewinds past the prompt and sends it again; `fork` moves onto a new session * holding the conversation up to the message, the old one kept on disk. */ async runTurnAction(turn: TurnAct, message: TurnMessage): Promise { const branch = this.ctx.sessionManager.getBranch(); const index = branch.findLastIndex( e => e.type === "message" && e.message.role === message.role && e.message.timestamp === message.at, ); const entry = branch[index]; if (!entry) { this.ctx.showStatus("That message is no longer on this branch"); return; } if (turn === "rewind") { this.showUserMessageSelector(entry.id); return; } if (turn === "fork") { await this.#forkAt(entry.id); return; } // The turn's prompt: the message itself, or the last one asked before the answer. const prompt = message.role === "user" ? entry : branch.slice(0, index).findLast(e => isUserRequestEntry(e)); if (!prompt) { this.ctx.showStatus("No prompt to resend before this answer"); return; } // Retry sends the prompt through the editor, which holds the user's unsent draft. if (turn === "retry" && (this.ctx.editor.getText().trim() || this.ctx.editor.pendingImages.length > 0)) { this.ctx.showStatus("Send or clear the draft to retry this turn"); return; } await this.#rewindFromTranscript(prompt.id, () => this.ctx.ui.requestRender(), turn === "retry"); } /** Fork from here: the session moves onto a new file holding the conversation up to `entryId`. */ async #forkAt(entryId: string): Promise { if (this.ctx.session.isStreaming) { this.ctx.showWarning("Wait for the current response to finish or abort it before forking."); return; } const previous = this.ctx.sessionManager.isSessionOnDisk() ? this.ctx.sessionManager.getSessionId() : undefined; try { if (!(await this.ctx.session.fork(entryId))) { this.ctx.showError("Fork failed (session not persisted or cancelled)"); return; } } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); return; } await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.statusLine.invalidate(); this.ctx.showStatus( previous ? `Forked into a new session · /resume ${previous} returns` : "Forked into a new session", ); } showCopySelector(): void { const entries = this.ctx.sessionManager.getBranch().filter(isTranscriptEntry); if (entries.length === 0) { this.ctx.showStatus("Nothing to copy yet."); return; } const done = () => { overlayHandle?.hide(); selector?.dispose(); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const selector = new CopySelectorComponent(entries, { ui: this.ctx.ui, getTool: name => this.ctx.session.getToolByName(name), isBuiltInTool: name => this.ctx.session.hasBuiltInTool(name), getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type), cwd: this.ctx.sessionManager.getCwd(), hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock, proseOnlyThinking: () => this.ctx.proseOnlyThinking, expandThinkingBlocks: () => this.ctx.expandThinkingBlocks, linkTargets: getAssistantMessageLinkTargets(this.ctx), requestRender: () => this.ctx.ui.requestRender(), onPick: (content, label) => { done(); if (!content.trim()) { this.ctx.showStatus("Nothing to copy in that item"); return; } void copyToClipboard(content); this.ctx.showStatus(`Copied ${label} to clipboard`); }, onOpen: (href, label) => { done(); openPath(href); this.ctx.showStatus(`Opening ${label}: ${href}`); }, onCancel: done, }); if (selector.targetCount === 0) { selector.dispose(); this.ctx.showStatus("Nothing to copy yet."); return; } const 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(); } 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, options) => { // Selecting the current leaf is normally a no-op (already there) — // unless it's an `ask` toolResult, in which case the re-answer flow // must still be allowed to reopen the picker even though the leaf // doesn't move (chatgpt-codex review on #5895). if (entryId === realLeafId) { const currentEntry = this.ctx.sessionManager.getEntry(entryId); const currentIsAskResult = currentEntry?.type === "message" && currentEntry.message.role === "toolResult" && currentEntry.message.toolName === "ask"; if (!currentIsAskResult) { done(); this.ctx.showStatus("Already at this point"); return; } } // Ask about summarization done(); // Close selector first // Pure-rewind probe (before navigation mutates the leaf): when the // target sits on the current leaf's path and no summary is added, // the post-navigation transcript is a strict prefix of the rendered // one and the tail can be dropped in place. const treeRewind = this.#treeRewindBoundary(entryId, realLeafId); // Loop until user makes a complete choice or cancels to tree. // Shift+Enter in the tree selector pre-answers "Summarize" and // skips the prompt entirely. let wantsSummary = options.summarize; let customInstructions: string | undefined; const branchSummariesEnabled = cfgBranchSummaryEnabled.get(settings); while (!wantsSummary && 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... (${appKey(this.ctx.keybindings, "app.interrupt")} to cancel)`, getSymbolTheme().spinnerFrames, ); this.ctx.statusContainer.addChild(summaryLoader); this.ctx.ui.requestRender(); } try { let result = await this.ctx.session.navigateTree(entryId, { summarize: wantsSummary, customInstructions, allowAskReopen: true, }); // Selecting an `ask` toolResult doesn't land the leaf directly — // re-open the original form first, then // complete the navigation as a new sibling branch (issue #5642). if (result.reopenAsk) { const reanswer = await this.#reanswerAsk(result.reopenAsk.html); if (!reanswer) { this.ctx.showStatus("Re-answer cancelled"); return; } result = await this.ctx.session.navigateTree(entryId, { summarize: wantsSummary, customInstructions, allowAskReopen: true, reanswerAskResult: reanswer, }); } 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). const fastRewind = treeRewind !== undefined && !wantsSummary && !result.summaryEntry && !result.askReanswerCommitted && this.ctx.sessionManager.getLeafId() === treeRewind.expectedLeafId && this.ctx.truncateTranscriptFromMessage(treeRewind.message); if (!fastRewind) { await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); } await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); } this.ctx.showStatus("Navigated to selected point"); // Re-answering a past `ask` commits a new sibling answer but, // unlike a live `ask`, leaves the agent idle. Resume it now — // after the transcript rebuild above — so the model consumes the // new answer without the resumed turn rendering against the stale // pre-rebuild UI (issue #6483). if (result.askReanswerCommitted) { this.ctx.session.resumeAfterAskReanswer(); } } 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(); }, cfgTreeFilterMode.get(settings), this.ctx.sessionManager.getSessionName(), ); return { component: selector, focus: selector }; }); } /** * First rendered message a pure tree rewind drops, plus the leaf id the * navigation is expected to land on. `targetId` must sit on the current * leaf's path; a user-request target rewinds PAST itself (navigateTree * moves the leaf to its parent and hands the draft back to the editor), * every other target keeps the target as the new leaf. Returns undefined * when the navigation is not a pure rewind or the boundary entry cannot * anchor an in-place truncation (non-message boundary; custom messages * render unkeyed components, so a skill/collab target takes the replay). */ #treeRewindBoundary( targetId: string, leafId: string | null, ): { message: AgentMessage; expectedLeafId: string } | undefined { if (!leafId) return undefined; const target = this.ctx.sessionManager.getEntry(targetId); if (!target) return undefined; const rewindsPastTarget = isUserRequestEntry(target); if (!rewindsPastTarget && target.type === "custom_message") return undefined; // Walk leaf → root: proves the target is on the current path and finds // the first entry the rewind drops. let firstDropped: SessionEntry | undefined; let cursor = this.ctx.sessionManager.getEntry(leafId); while (cursor && cursor.id !== targetId) { firstDropped = cursor; cursor = cursor.parentId ? this.ctx.sessionManager.getEntry(cursor.parentId) : undefined; } if (!cursor) return undefined; const boundary = rewindsPastTarget ? target : firstDropped; if (boundary?.type !== "message") return undefined; // A root rewind (expected leaf null) empties the transcript but may leave // components rendered before the first message stale — take the // destructive replay instead. const expectedLeafId = rewindsPastTarget ? target.parentId : targetId; if (expectedLeafId === null) return undefined; return { message: boundary.message, expectedLeafId, }; } /** * Re-open the `ask` form with the original `html` (issue #5642): * runs a standalone `AskTool.execute()` outside a normal agent turn, * reusing the same dialog primitives a live `ask` tool call gets. * Returns `undefined` when the user cancels — mirrors `navigateTree`'s * cancellation contract instead of throwing. */ async #reanswerAsk(html: string): Promise | undefined> { const uiContext = this.ctx.getToolUIContext(); if (!uiContext) { this.ctx.showError("Ask tool UI is not ready"); return undefined; } const toolSession: ToolSession = { cwd: this.ctx.sessionManager.getCwd(), hasUI: true, settings: this.ctx.settings, getSessionFile: () => this.ctx.sessionManager.getSessionFile() ?? null, getSessionSpawns: () => null, getPlanModeState: () => this.ctx.session.getPlanModeState(), }; const askTool = new AskTool(toolSession); const context = this.ctx.session.buildAskReanswerContext(uiContext); try { return await askTool.execute("tree-reanswer", { html }, undefined, undefined, context); } catch (error) { if (error instanceof ToolAbortError) return undefined; throw error; } } async showSessionSelector(source?: ForeignSessionSource, onSelect?: SessionPickHandler): Promise { let sessions: SessionInfo[]; let onSelectSession: (session: SessionInfo) => Promise; let selectorOptions: SessionSelectorOptions; if (source) { const sourceName = foreignSessionSourceName(source); const store = createForeignSessionStore(source); let foreignSessions: ForeignSessionInfo[]; try { foreignSessions = await store.list(); } catch (error) { this.ctx.showError( `Failed to list ${sourceName} sessions: ${error instanceof Error ? error.message : String(error)}`, ); return; } if (foreignSessions.length === 0) { this.ctx.showWarning(`No ${sourceName} sessions found`); return; } const foreignByPath = new Map(foreignSessions.map(session => [session.path, session])); sessions = foreignSessions.map(foreignSessionInfoToSessionInfo); onSelectSession = async session => { try { await this.ctx.settings.flush(); } catch (error) { this.ctx.showError( `Failed to save pending settings: ${error instanceof Error ? error.message : String(error)}`, ); return false; } const foreignSession = foreignByPath.get(session.path); if (!foreignSession) throw new Error(`Selected ${sourceName} session is no longer available`); const imported = await persistForeignSession(store, foreignSession, { fallbackCwd: this.ctx.sessionManager.getCwd(), suppressBreadcrumb: true, }); const sessionFile = imported.getSessionFile(); if (!sessionFile) throw new Error(`Failed to persist ${sourceName} session`); await imported.close(); return await this.handleResumeSession(sessionFile, { settingsFlushed: true }); }; selectorOptions = { title: `Import ${sourceName} Session`, scopeLabel: false, showCwd: true, }; } else { const [listing, pinnedIds] = await Promise.all([ SessionManager.listForPicker( this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.getSessionDir(), undefined, cfgSessionWorktreeScope.get(this.ctx.settings), ), loadPinnedSessionIds(), ]); sessions = listing.sessions; const historyStorage = this.ctx.historyStorage; const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined; onSelectSession = session => this.handleResumeSession(session.path, { followContinuation: true }); selectorOptions = { ...(!onSelect ? { 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 (error) { throw new Error( `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } }, onRename: async (session: SessionInfo, title: string): Promise => { if (this.ctx.sessionManager.getSessionFile() === session.path) { // A session that has only been read does not hold // its ownership lease, so another omp process can // claim it; the live rename would then fork onto // a sibling and still report success, leaving the // row here pointing at the old path. Claim the // lease up front and refuse while it is held by // someone else. const claim = new FileSessionStorage().claimSession(session.id, session.path); if (!claim) { throw new Error( "Failed to rename session: it is currently in use by another omp process", ); } try { const renamed = await this.ctx.sessionManager.setSessionName(title, "user"); return renamed ? (this.ctx.sessionManager.getSessionName() ?? null) : null; } finally { claim(); } } const renamed = await SessionManager.renameSessionTitle(session.path, title, { cwd: session.cwd, }); // The picker renders the failure itself; throwing // hands it the reason so it can show one message // instead of two. if (!renamed) { throw new Error("Failed to rename session: it is currently in use or no longer available"); } return renamed; }, onSetPinned: async (session: SessionInfo, pinned: boolean) => { await setSessionPinned(session.id, pinned); this.ctx.applySessionPin(session.id, pinned); }, } : { title: "Select a Session to Consult", actionLabel: "Select", scopeLabel: false, showCwd: true, }), historyMatcher, completeSessions: listing.complete, loadAllSessions: () => SessionManager.listAllForPicker(), pinnedIds, // Live getter so detach/newSession stays accurate; tolerant of partial // contexts and in-memory sessions (undefined file means no marker). currentSessionPath: () => this.ctx.sessionManager.getSessionFile?.() ?? undefined, }; } // Keep the fullscreen picker on the alternate buffer while a selected // session is loaded and its transcript is rebuilt. const done = () => { overlayHandle?.hide(); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const selector = new SessionSelectorComponent( sessions, async (session: SessionInfo) => { selector.lockInput(); if (onSelect) { // Selection-only callers may open a follow-up picker; remove this // fullscreen overlay before invoking them. done(); try { await onSelect(session); } catch (error) { this.ctx.showError(sanitizeErrorLine(error)); } return; } let keepOpen = false; try { const success = await onSelectSession(session); if (!success) { keepOpen = true; selector.unlockInput(); this.ctx.ui.requestRender(); } } catch (error) { this.ctx.showError(error instanceof Error ? error.message : String(error)); } finally { if (!keepOpen) done(); } }, done, () => { // Release the alt buffer before teardown: shutdown() awaits flush/save/ // dispose/drain before stop() leaves the alt screen. done(); void this.ctx.shutdown(); }, { ...selectorOptions, getTerminalRows: () => this.ctx.ui.terminal.rows, fillHeight: true, }, ); selector.setOnRequestRender(() => this.ctx.ui.requestRender()); const 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 { const currentSessionFile = this.ctx.sessionManager.getSessionFile(); if (currentSessionFile !== sessionPath) { return true; } await this.ctx.prepareSessionSwitch(); const detached = await this.ctx.session.newSession(); if (!detached) { return false; } this.ctx.resetObserverRegistry(); this.#refreshSessionTerminalTitle(); this.ctx.clearTransientSessionUi(); this.ctx.statusLine.invalidate(); this.ctx.statusLine.resetActiveTime(); this.ctx.ui.requestRender(); this.ctx.updateEditorBorderColor(); await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return true; } async handleResumeSession( sessionPath: string, options?: ResumeSessionOptions & { settingsFlushed?: boolean }, ): Promise { const previousCwd = this.ctx.sessionManager.getCwd(); // Flush pending settings writes before switching sessions so a save // failure leaves the session, process project dir, and Settings in the // source scope. if (!options?.settingsFlushed) { try { await this.ctx.settings.flush(); } catch (err) { this.ctx.showError(`Failed to save pending settings: ${err instanceof Error ? err.message : String(err)}`); return false; } } // The conversation may have gone on in a sibling file while another process held this one. const continuation = options?.continuation ?? (options?.followContinuation ? await findSessionContinuation(sessionPath) : undefined); const target = await this.#relocateFromRemovedWorktree(continuation?.session.path ?? sessionPath); if (!target) return false; await this.ctx.prepareSessionSwitch(); this.ctx.resetObserverRegistry(); // AgentSession owns the transaction. It restores the complete source state // if applying the target project's cwd fails, including in-memory sessions. let modelFallbackWarning: string | undefined; if ( (await this.ctx.session.switchSession(target.sessionPath, { onCwdChange: async (newCwd, sourceCwd) => { if (normalizePathForComparison(newCwd) === normalizePathForComparison(sourceCwd)) return true; return this.ctx.applyCwdChange(newCwd); }, onModelFallback: warning => { modelFallbackWarning = warning; }, })) === false ) { return false; } this.ctx.clearTransientSessionUi(); const newCwd = this.ctx.sessionManager.getCwd(); const movedProject = normalizePathForComparison(newCwd) !== normalizePathForComparison(previousCwd); this.#refreshSessionTerminalTitle(); this.ctx.updateEditorBorderColor(); // Clear and re-render the chat await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus( target.movedFrom ? `Resumed session, moved here from removed worktree ${shortenPath(target.movedFrom)}` : movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session", ); if (continuation) this.ctx.showWarning(formatSessionContinuationNotice(continuation)); if (modelFallbackWarning) this.ctx.showWarning(modelFallbackWarning); return true; } /** * Move a session recorded in a removed worktree of this repository into the * current folder before resuming it: its own directory cannot be entered. * Returns the session file to switch to, or undefined after reporting a failed move. */ async #relocateFromRemovedWorktree( sessionPath: string, ): Promise<{ sessionPath: string; movedFrom?: string } | undefined> { const sessionManager = this.ctx.sessionManager; // Never move the live session out from under its own writer. if (sessionManager.getSessionFile() === sessionPath) return { sessionPath }; const cwd = sessionManager.getCwd(); const sessionDir = sessionManager.getSessionDir(); const info = await readSessionInfo(sessionPath); if (!info || !(await SessionManager.isFromRemovedWorktree(info, cwd, sessionDir))) return { sessionPath }; try { const relocated = await SessionManager.openRelocated(sessionPath, info.cwd, cwd, sessionDir); const movedPath = relocated.getSessionFile() ?? sessionPath; await relocated.close(); return { sessionPath: movedPath, movedFrom: info.cwd }; } catch (error) { this.ctx.showError( `Could not move the session out of removed worktree ${shortenPath(info.cwd)}: ${error instanceof Error ? error.message : String(error)}`, ); return undefined; } } async handleSessionDeleteCommand(): Promise { 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 { this.ctx.showStatus(`Logging in to ${providerId}…`); const { getOAuthCredentialProvider, LoginDialogComponent, PASTE_CODE_LOGIN_PROVIDERS } = await loadProviderAuthUi(); 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); }, openPath, ); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(dialog); this.ctx.ui.setFocus(dialog); this.ctx.ui.requestRender(); try { const identity = await this.ctx.session.modelRegistry.authStorage.oauth.login(providerId as OAuthProvider, { signal: dialog.signal, onBrowserSession: captureBrowserSession, 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); }, onPrompt: prompt => dialog.showPrompt(prompt), onProgress: (message: string) => { dialog.showProgress(message); }, // Paste-code providers (e.g. Codex) may need the user to paste the // fallback redirect URL when the loopback callback can't complete // (headless/remote/Windows). Mount a focused input in the dialog so // the paste lands somewhere the OAuth flow consumes — the hidden // editor's `/login ` path is unreachable while the dialog holds // focus (#5339). onManualCodeInput: useManualInput ? signal => dialog.showManualInput( `Paste the authorization code (or full redirect URL), then press ${editorKey("tui.input.submit")}:`, signal, ) : undefined, }); // Scope the post-login refresh to the just-authenticated provider with an // `online` strategy: the default all-provider `online-if-uncached` reuses // a fresh authoritative cache row (e.g. an empty result fetched before // login), so newly persisted credentials would never re-run discovery and // models would stay unavailable in-session (#5780). Unrelated providers // are left untouched. `refreshProvider` swallows discovery failures, so // awaiting cannot reject the login. await this.ctx.session.modelRegistry.refreshProvider(getOAuthCredentialProvider(providerId), "online"); await this.ctx.session.refreshScopedModels(); const block = new TranscriptBlock(); // Name the account (and Anthropic organization) that was stored so a // login that lands on an unintended account/subscription is visible // immediately instead of silently replacing an existing registration. const who = formatLoginIdentity(identity); block.addChild( new Text( theme.fg( "success", `${theme.status.success} Successfully logged in to ${providerId}${who ? ` as ${who}` : ""}`, ), 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 { restoreEditor(); } } async #handleCredentialLogout(providerId: string, account: LogoutAccount): Promise { try { const { removed, remainingSource } = await logoutCredential( this.ctx.session, providerId, account.credentialId, ); if (!removed) { this.ctx.showError(`Logout skipped: ${account.label} is no longer stored for ${providerId}.`); return; } 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)); 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 { const authStorage = this.ctx.session.modelRegistry.authStorage; let accounts: LogoutAccount[]; try { accounts = await listLogoutAccounts(authStorage, providerId, this.ctx.session.sessionId); } catch (error: unknown) { this.ctx.showError( `Could not load stored credentials: ${error instanceof Error ? error.message : String(error)}`, ); return; } const { getOAuthProviders, LogoutAccountSelectorComponent } = await loadProviderAuthUi(); const provider = getOAuthProviders().find(candidate => candidate.id === providerId); if (accounts.length === 0) { const source = authStorage.keys.describe( provider?.storeCredentialsAs ?? 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 { if (providerId) { if (mode === "login") { await this.#handleOAuthLogin(providerId); } else { await this.#showOAuthLogoutAccountSelector(providerId); } return; } const { getOAuthProviders, OAuthSelectorComponent } = await loadProviderAuthUi(); if (mode === "logout") { await this.#refreshOAuthProviderAuthState(); const oauthProviders = getOAuthProviders(); const loggedInProviders = oauthProviders.filter(provider => this.ctx.session.modelRegistry.authStorage.credentials.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 => { const 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(); }, { disabledProviders: cfgDisabledProviders.get(settings), 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 showSessionPinSelector(): Promise { const session = this.ctx.session; if (session.isStreaming) { this.ctx.showStatus("Cannot pin an account while the session is streaming."); return; } this.ctx.showStatus("Loading provider accounts…", { dim: true }); let accountList: SessionOAuthAccountList | undefined; try { accountList = await session.listCurrentProviderOAuthAccounts(); } catch (error) { this.ctx.showError( `Could not load provider accounts: ${error instanceof Error ? error.message : String(error)}`, ); return; } if (!accountList) { this.ctx.showStatus("Select a model before pinning a provider account."); return; } const { getOAuthProviders } = await loadProviderAuthUi(); const provider = getOAuthProviders().find(candidate => candidate.id === accountList.provider); const providerName = provider?.name ?? accountList.provider; const accounts = toSessionPinAccounts(accountList.accounts); if (accounts.length === 0) { const source = session.modelRegistry.authStorage.keys.describe(accountList.provider, session.sessionId); this.ctx.showStatus( source ? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.` : `No stored OAuth accounts for ${providerName}. Use /login to add one.`, ); return; } this.showSelector(done => { const selector = new SessionAccountSelectorComponent( providerName, accounts, account => { done(); if (!session.pinCurrentProviderOAuthAccount(account.credentialId)) { this.ctx.showWarning(`${account.label} is no longer available to pin.`); return; } this.ctx.showStatus(`Pinned ${account.label} to this session for ${providerName}.`); this.ctx.statusLine.invalidate(); this.ctx.ui.requestRender(); }, () => { done(); this.ctx.ui.requestRender(); }, ); return { component: selector, focus: selector }; }); } async showResetUsageSelector(): Promise { 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( sanitizeText( `Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`.replace( /[\r\n\t]+/g, " ", ), ), ); return; } const accounts = toResetUsageAccounts(statuses); if (accounts.length === 0) { this.ctx.showStatus("No provider 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 { this.ctx.showStatus( `Spending 1 saved reset for ${sanitizeText(account.label.replace(/[\r\n\t]+/g, " "))} (${account.providerLabel})…`, { dim: true }, ); let outcome: ResetCreditRedeemOutcome; try { outcome = await this.ctx.session.redeemResetCredit(account.target); } catch (error) { this.ctx.showError( sanitizeText( `Reset failed for ${account.label}: ${error instanceof Error ? error.message : String(error)}`.replace( /[\r\n\t]+/g, " ", ), ), ); return; } const message = sanitizeText(describeRedeemOutcome(outcome, account.label).replace(/[\r\n\t]+/g, " ")); 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 { const { DebugSelectorComponent } = await import("../../debug"); this.showSelector(done => { const selector = new DebugSelectorComponent(this.ctx, done); return { component: selector, focus: selector }; }); } /** Open the `/effort` picker for the viewed session (the focused subagent's, else main). */ showThinkingSelector(): void { const viewed = this.ctx.viewSession; const configured = viewed.configuredThinkingLevel(); this.showSelector(done => { const selector = new ThinkingSelectorComponent( configured === ThinkingLevel.Inherit ? ThinkingLevel.Off : configured, viewed.getAvailableEffortSelectors(), level => { done(); // thinking_level_changed refreshes the status line and editor border. this.ctx.viewSession.setThinkingLevel(level); this.ctx.ui.requestRender(); }, () => { done(); this.ctx.ui.requestRender(); }, ); return { component: selector, focus: selector.getSelectList() }; }); } showAgentHub(observers: SessionObserverRegistry, options?: AgentHubOpenOptions): void { // A repeat request reuses the open hub, still honoring its deep link (`/hub` → activity). const reuseOpenHub = (): boolean => { const open = this.#openMenus.get("agent-hub")?.component; if (!this.#focusOpenMenu("agent-hub")) return false; if (options?.initialSection && open instanceof AgentHubOverlayComponent) open.showSection(options.initialSection); return true; }; if (reuseOpenHub()) return; const hubKeys = [ ...this.ctx.keybindings.getKeys("app.agents.hub"), ...this.ctx.keybindings.getKeys("app.session.observe"), ]; let menu: OpenMenu | undefined; let closed = false; const done = () => { if (closed) return; closed = true; hub.dispose(); if (!menu) { // A gated empty Hub never mounted. Restoring editor focus here // would steal focus from a menu opened meanwhile. this.ctx.ui.requestRender(); return; } menu.handle?.hide(); this.#releaseMenu("agent-hub", menu); this.focusActiveEditorArea(); this.ctx.ui.requestRender(); }; const hub = new AgentHubOverlayComponent({ ...createAgentHubRuntime({ settings: this.ctx.settings, registry: this.ctx.collabGuest?.agentRegistry, remote: this.ctx.collabGuest?.hubRemote, sessionFile: this.ctx.sessionManager.getSessionFile() ?? null, }), observers, hubKeys, expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), initialSection: options?.initialSection, onDone: done, requestRender: () => this.ctx.ui.requestRender(), remote: this.ctx.collabGuest?.hubRemote, ui: this.ctx.ui, getTool: name => this.ctx.session.getToolByName(name), isBuiltInTool: name => this.ctx.session.hasBuiltInTool(name), getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type), cwd: this.ctx.sessionManager.getCwd(), hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock, proseOnlyThinking: () => this.ctx.proseOnlyThinking, expandThinkingBlocks: () => this.ctx.expandThinkingBlocks, focusAgent: id => this.ctx.focusAgentSession(id), sessionFile: this.ctx.sessionManager.getSessionFile() ?? null, }); const showReadyHub = () => { if (closed) return; // The double-← gesture stays inert when neither live nor persisted // subagents are available, so wait for discovery before making the gate. if (options?.requireContent && hub.isEmpty) { done(); return; } // Another open mounted while discovery was pending: keep that one. if (reuseOpenHub()) { done(); return; } // Prime the detector before the first frame when the editor's double-← // gesture opened the hub, so the next single ← dismisses it. if (options?.armCloseTap) hub.armCloseTap(); menu = this.#claimMenu("agent-hub"); this.#mountMenu(menu, hub, () => this.#showFullscreenMenu(hub)); }; if (options?.requireContent && hub.isEmpty) { void hub.persistedSubagentsReady.then(showReadyHub); } else { showReadyHub(); } } }