From af9e8546a965217da3cc5b5ec47b19b3d2c6c8d0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Jul 2026 07:56:40 +0200 Subject: [PATCH] feat: implemented session account selection and pinning via slash command - Add `pinSessionOAuthAccount` storage method and active account flag to the auth storage API. - Introduce session account selector component, controller logic, and interactive mode delegation. - Implement the `/session pin` builtin slash command with text listing and account pinning capabilities. - Add unit tests covering session account selection, component navigation, and command handling. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/auth-storage.ts | 33 +++++- .../auth-storage-oauth-account-select.test.ts | 38 ++++++ packages/coding-agent/CHANGELOG.md | 1 + .../components/session-account-selector.ts | 62 ++++++++++ .../modes/controllers/selector-controller.ts | 62 ++++++++++ .../src/modes/interactive-mode.ts | 4 + packages/coding-agent/src/modes/types.ts | 1 + .../src/session/agent-session-types.ts | 7 ++ .../coding-agent/src/session/agent-session.ts | 23 ++++ .../coding-agent/src/session/auth-storage.ts | 1 + .../src/slash-commands/builtin-registry.ts | 111 ++++++++++++++++-- .../src/slash-commands/helpers/session-pin.ts | 44 +++++++ .../session-account-selector.test.ts | 53 +++++++++ .../test/slash-commands/session.test.ts | 28 +++++ 15 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/session-account-selector.ts create mode 100644 packages/coding-agent/src/slash-commands/helpers/session-pin.ts create mode 100644 packages/coding-agent/test/modes/components/session-account-selector.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 271ee2e36..0be09bc77 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added OAuth account session pinning and active status tracking in storage - Added OpenAI Responses native computer-use transport, including batched actions and exact `computer_call`/`computer_call_output` replay with pending/acknowledged safety checks and `image_url`/`file_id` output references. Models without native support receive the same action surface as a regular function tool; provider-specific tool-choice forcing is used where supported. - Added `PI_CODEX_RESPONSES_LITE` to override the catalog-selected Codex Responses transport for diagnostics (`1`/`true` forces Lite; `0`/`false` forces the standard body). diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 9525f57f0..c01c3220e 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -830,6 +830,8 @@ export interface OAuthAccountSummary { /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */ orgId?: string; orgName?: string; + /** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */ + active: boolean; } export interface InvalidateCredentialMatchingOptions { signal?: AbortSignal; @@ -5202,11 +5204,20 @@ export class AuthStorage { * order, WITHOUT refreshing any token. The array position (0-based) is the * selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth * account" UI should render `position + 1`. + * + * When `sessionId` is supplied, the session-sticky OAuth credential is marked + * `active`. No account is active before that session has resolved or pinned a + * credential. */ - listOAuthAccounts(provider: string): OAuthAccountSummary[] { + listOAuthAccounts(provider: string, sessionId?: string): OAuthAccountSummary[] { if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) { return []; } + const sessionCredential = this.#getSessionCredential(provider, sessionId); + const activeCredentialId = + sessionCredential?.type === "oauth" + ? this.#getStoredCredentials(provider)[sessionCredential.index]?.id + : undefined; return this.#getStoredOAuthSelections(provider).map((selection, position) => ({ position, credentialId: selection.credentialId, @@ -5216,9 +5227,29 @@ export class AuthStorage { enterpriseUrl: selection.credential.enterpriseUrl, orgId: selection.credential.orgId, orgName: selection.credential.orgName, + active: selection.credentialId === activeCredentialId, })); } + /** + * Pin one stored OAuth account as this session's preferred credential. + * + * The durable credential id keeps the pin stable across credential refreshes, + * storage reordering, and process restarts. Normal auth retry and usage-limit + * handling may still route around an unavailable account. + */ + pinSessionOAuthAccount(provider: string, sessionId: string, credentialId: number): boolean { + if (!sessionId || this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) { + return false; + } + const stored = this.#getStoredCredentials(provider); + const index = stored.findIndex(entry => entry.id === credentialId); + const target = stored[index]; + if (!target || target.credential.type !== "oauth") return false; + this.#recordSessionCredential(provider, sessionId, "oauth", index); + return true; + } + /** * Resolve every stored OAuth credential for `provider` independently. * diff --git a/packages/ai/test/auth-storage-oauth-account-select.test.ts b/packages/ai/test/auth-storage-oauth-account-select.test.ts index cf7d50593..2e3034281 100644 --- a/packages/ai/test/auth-storage-oauth-account-select.test.ts +++ b/packages/ai/test/auth-storage-oauth-account-select.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage"; +import { withOAuthAccess } from "@oh-my-pi/pi-ai/auth-retry"; import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth"; const PROVIDER = "unit-oauth-select"; @@ -55,6 +56,43 @@ describe("AuthStorage OAuth account selection", () => { expect(refreshSpy).not.toHaveBeenCalled(); }); + test("pinSessionOAuthAccount selects and restores the exact stored account", async () => { + const storage = authStorage; + const credentialStore = store; + if (!storage || !credentialStore) throw new Error("test setup failed"); + vi.spyOn(oauthUtils, "getOAuthApiKey").mockImplementation(async (provider, credentials) => { + const credential = credentials[provider]; + return credential ? { newCredentials: credential, apiKey: credential.access } : null; + }); + await storage.set(PROVIDER, [oauthCredential("a"), oauthCredential("b"), oauthCredential("c")]); + const accounts = storage.listOAuthAccounts(PROVIDER, "session-pin"); + const target = accounts[1]; + if (!target) throw new Error("expected second OAuth account"); + + expect(accounts.some(account => account.active)).toBe(false); + expect(storage.pinSessionOAuthAccount(PROVIDER, "session-pin", -1)).toBe(false); + expect(storage.pinSessionOAuthAccount(PROVIDER, "session-pin", target.credentialId)).toBe(true); + expect(storage.getOAuthAccountIdentity(PROVIDER, "session-pin")?.email).toBe("b@example.com"); + expect( + storage + .listOAuthAccounts(PROVIDER, "session-pin") + .filter(account => account.active) + .map(account => account.email), + ).toEqual(["b@example.com"]); + expect( + await withOAuthAccess(storage, PROVIDER, access => Promise.resolve(access.email), { + sessionId: "session-pin", + }), + ).toBe("b@example.com"); + + const restored = new AuthStorage(credentialStore); + await restored.reload(); + expect(restored.getOAuthAccountIdentity(PROVIDER, "session-pin")?.email).toBe("b@example.com"); + expect(restored.listOAuthAccounts(PROVIDER, "session-pin").find(account => account.active)?.credentialId).toBe( + target.credentialId, + ); + }); + test("getOAuthAccessAt resolves the credential at the requested position and touches only that one", async () => { const storage = authStorage; if (!storage) throw new Error("test setup failed"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27ede5c6d..3eb747761 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added the `/session pin` subcommand and account picker to pin provider OAuth accounts for the current session - Added the disabled-by-default `computer` essential tool with configurable enablement, backend, display, and maximum width/height settings. Native desktop execution runs through a `DesktopSession` worker; observation uses read approval, input uses exec approval, and provider checks always prompt and fail closed. - Added the `/computer` slash command (`on`/`off`/`status`/toggle) to enable or disable the computer tool for the current session without persisting settings. - Exposed `computer` to models without native OpenAI computer-use support as a regular function tool with a typed GA action schema; the same native desktop backend and approval policy apply on both paths. diff --git a/packages/coding-agent/src/modes/components/session-account-selector.ts b/packages/coding-agent/src/modes/components/session-account-selector.ts new file mode 100644 index 000000000..67deebc27 --- /dev/null +++ b/packages/coding-agent/src/modes/components/session-account-selector.ts @@ -0,0 +1,62 @@ +import { Container, type SelectItem, SelectList, type SgrMouseEvent, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; +import { getSelectListTheme, theme } from "../../modes/theme/theme"; +import type { SessionPinAccount } from "../../slash-commands/helpers/session-pin"; +import { DynamicBorder } from "./dynamic-border"; + +const ACCOUNT_SELECTOR_MAX_VISIBLE = 10; +const ACCOUNT_LIST_ROW_OFFSET = 4; + +/** Account picker opened by `/session pin` for the current model provider. */ +export class SessionAccountSelectorComponent extends Container { + #selectList: SelectList; + + constructor( + providerName: string, + accounts: readonly SessionPinAccount[], + onSelect: (account: SessionPinAccount) => void, + onCancel: () => void, + ) { + super(); + const accountsByValue = new Map(); + const items: SelectItem[] = accounts.map(account => { + const value = String(account.credentialId); + accountsByValue.set(value, account); + return { + value, + label: account.label, + description: account.active ? "active for this session" : undefined, + }; + }); + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new TruncatedText(theme.bold(`Select a ${providerName} account for this session:`))); + this.addChild(new Spacer(1)); + + this.#selectList = new SelectList( + items, + Math.min(Math.max(items.length, 1), ACCOUNT_SELECTOR_MAX_VISIBLE), + getSelectListTheme(), + ); + const activeIndex = accounts.findIndex(account => account.active); + if (activeIndex >= 0) this.#selectList.setSelectedIndex(activeIndex); + this.#selectList.onSelect = item => { + const account = accountsByValue.get(item.value); + if (account) onSelect(account); + }; + this.#selectList.onCancel = onCancel; + this.addChild(this.#selectList); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + /** Forward keyboard navigation and cancellation when the wrapper owns focus. */ + handleInput(keyData: string): void { + this.#selectList.handleInput(keyData); + } + + /** Route mouse selection through the title rows into the account list. */ + routeMouse(event: SgrMouseEvent, line: number, col: number): void { + this.#selectList.routeMouse(event, line - ACCOUNT_LIST_ROW_OFFSET, col); + } +} diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index c1ac0c477..8a1dee626 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -41,6 +41,7 @@ import { } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage"; +import type { SessionOAuthAccountList } from "../../session/agent-session-types"; import type { SessionInfo } from "../../session/session-listing"; import { SessionManager } from "../../session/session-manager"; import { FileSessionStorage } from "../../session/session-storage"; @@ -50,6 +51,7 @@ import { type ResetUsageAccount, toResetUsageAccounts, } from "../../slash-commands/helpers/reset-usage"; +import { toSessionPinAccounts } from "../../slash-commands/helpers/session-pin"; import { AUTO_THINKING, type ConfiguredThinkingLevel, @@ -84,6 +86,7 @@ import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; import { ResetUsageSelectorComponent } from "../components/reset-usage-selector"; import { renderSegmentTrack } from "../components/segment-track"; +import { SessionAccountSelectorComponent } from "../components/session-account-selector"; import { SessionSelectorComponent } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; @@ -1743,6 +1746,65 @@ export class SelectorController { }); } + 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 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.describeCredentialSource( + 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 }); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 86aa66fae..a92f495e0 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4639,6 +4639,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.#selectorController.showOAuthSelector(mode, providerId); } + showSessionPinSelector(): Promise { + return this.#selectorController.showSessionPinSelector(); + } + showResetUsageSelector(): Promise { return this.#selectorController.showResetUsageSelector(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 346c930b4..be70396c0 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -383,6 +383,7 @@ export interface InteractiveModeContext { handleResumeSession(sessionPath: string): Promise; handleSessionDeleteCommand(): Promise; showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise; + showSessionPinSelector(): Promise; showResetUsageSelector(): Promise; showProviderSetup(): Promise; showHookConfirm(title: string, message: string): Promise; diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index a86a0f44b..e9a3bb93a 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -5,6 +5,7 @@ import type { Message, MessageAttribution, Model, + OAuthAccountSummary, ServiceTierByFamily, SimpleStreamOptions, ToolChoice, @@ -325,6 +326,12 @@ export interface SessionStats { contextUsage?: ContextUsage; } +/** Stored OAuth accounts available to the current model provider. */ +export interface SessionOAuthAccountList { + provider: string; + accounts: OAuthAccountSummary[]; +} + /** IDs for a newly created session and the session it replaced. */ export interface FreshSessionResult { previousSessionId: string; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 97ed867ee..0d9ef27c4 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -218,6 +218,7 @@ import type { RoleModelCycle, RoleModelCycleResult, SessionHandoffOptions, + SessionOAuthAccountList, SessionStats, UsageFallbackConfirmation, } from "./agent-session-types"; @@ -7775,6 +7776,28 @@ export class AgentSession { return [...selectors].sort((left, right) => left.localeCompare(right)); } + /** List stored OAuth accounts for the current model provider and mark this session's active account. */ + async listCurrentProviderOAuthAccounts(): Promise { + const provider = this.model?.provider; + if (!provider) return undefined; + const authStorage = this.#modelRegistry.authStorage; + await authStorage.reload(); + return { + provider, + accounts: authStorage.listOAuthAccounts(provider, this.sessionId), + }; + } + + /** + * Pin a stored OAuth account to the current model provider for this session. + * Returns false while streaming or when the credential is no longer available. + */ + pinCurrentProviderOAuthAccount(credentialId: number): boolean { + const provider = this.model?.provider; + if (!provider || this.isStreaming) return false; + return this.#modelRegistry.authStorage.pinSessionOAuthAccount(provider, this.sessionId, credentialId); + } + /** * Redeem one saved Codex rate-limit reset for a specific account, injecting * the provider base URL like {@link AgentSession.fetchUsageReports}. Powers diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 458e29d14..0b58ed30a 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -12,6 +12,7 @@ export type { AuthStorageOptions, CredentialOrigin, CredentialOriginKind, + OAuthAccountSummary, OAuthAccountIdentity, OAuthCredential, ResetCreditAccountStatus, diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 73f72cbd1..40b219a44 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -33,6 +33,7 @@ import { theme } from "../modes/theme/theme"; import type { InteractiveModeContext } from "../modes/types"; import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets"; import type { AgentSession, FreshSessionResult } from "../session/agent-session"; +import type { SessionOAuthAccountList } from "../session/agent-session-types"; import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes"; import { resolveResumableSession } from "../session/session-listing"; import { formatShakeSummary, type ShakeMode } from "../session/shake-types"; @@ -52,6 +53,7 @@ import { createMarketplaceManager } from "./helpers/marketplace-manager"; import { handleMcpAcp } from "./helpers/mcp"; import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse"; import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage"; +import { matchSessionPinAccounts, toSessionPinAccounts } from "./helpers/session-pin"; import { handleSshAcp } from "./helpers/ssh"; import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard"; import { handleTodoAcp } from "./helpers/todo"; @@ -216,6 +218,74 @@ async function handleUsageResetCommand( await output(describeRedeemOutcome(outcome, target.label)); } +async function handleSessionPinCommand( + arg: string, + session: AgentSession, + output: SlashCommandRuntime["output"], +): Promise { + if (session.isStreaming) { + await output("Cannot pin an account while the session is streaming."); + return; + } + let accountList: SessionOAuthAccountList | undefined; + try { + accountList = await session.listCurrentProviderOAuthAccounts(); + } catch (error) { + await output(`Could not load provider accounts: ${errorMessage(error)}`); + return; + } + if (!accountList) { + await output("Select a model before pinning a provider account."); + return; + } + 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.describeCredentialSource( + accountList.provider, + session.sessionId, + ); + await output( + source + ? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.` + : `No stored OAuth accounts for ${providerName}. Use /login to add one.`, + ); + return; + } + + const selector = arg.trim(); + if (!selector) { + const lines = [`OAuth accounts for ${providerName}:`]; + for (const account of accounts) { + lines.push(`${account.position + 1}. ${account.label}${account.active ? " (active)" : ""}`); + } + lines.push("", "Pin one with `/session pin `."); + await output(lines.join("\n")); + return; + } + + const matches = matchSessionPinAccounts(accounts, selector); + if (matches.length === 0) { + await output(`No ${providerName} account matches "${selector}".`); + return; + } + if (matches.length > 1) { + await output( + `"${selector}" matches multiple ${providerName} accounts: ${matches + .map(account => `${account.position + 1}. ${account.label}`) + .join(", ")}. Use the account number.`, + ); + return; + } + const account = matches[0]; + if (!account || !session.pinCurrentProviderOAuthAccount(account.credentialId)) { + await output(`${account?.label ?? selector} is no longer available to pin.`); + return; + } + await output(`Pinned ${account.label} to this session for ${providerName}.`); +} + /** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */ function parseShakeMode(args: string): ShakeMode | { error: string } { const verb = args.trim().toLowerCase(); @@ -1044,15 +1114,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "session", description: "Session management commands", - acpDescription: "Show session information", - acpInputHint: "info|delete", + acpDescription: "Show or configure the current session", + acpInputHint: "[info|delete|pin [account]]", subcommands: [ { name: "info", description: "Show session info and stats" }, { name: "delete", description: "Delete current session and return to selector" }, + { + name: "pin", + description: "Pin the current provider to a stored OAuth account", + usage: "[account]", + }, ], allowArgs: true, handle: async (command, runtime) => { - if (!command.args || command.args === "info") { + const { verb, rest } = parseSubcommand(command.args); + if (!verb || (verb === "info" && !rest)) { await runtime.output( [ `Session: ${runtime.session.sessionId}`, @@ -1062,7 +1138,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ); return commandConsumed(); } - if (command.args === "delete") { + if (verb === "delete" && !rest) { if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime); const sessionFile = runtime.sessionManager.getSessionFile(); if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime); @@ -1081,17 +1157,34 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ ); return commandConsumed(); } - return usage("Usage: /session [info|delete]", runtime); + if (verb === "pin") { + await handleSessionPinCommand(rest, runtime.session, runtime.output); + return commandConsumed(); + } + return usage("Usage: /session [info|delete|pin [account]]", runtime); }, handleTui: async (command, runtime) => { - const sub = command.args.trim().toLowerCase() || "info"; - if (sub === "delete") { + const { verb, rest } = parseSubcommand(command.args); + if (verb === "delete" && !rest) { runtime.ctx.editor.setText(""); await runtime.ctx.handleSessionDeleteCommand(); return; } - // Default: show session info - await runtime.ctx.handleSessionCommand(); + if (verb === "pin") { + if (rest) { + await handleSessionPinCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text)); + refreshStatusLine(runtime.ctx); + } else { + await runtime.ctx.showSessionPinSelector(); + } + runtime.ctx.editor.setText(""); + return; + } + if (!verb || (verb === "info" && !rest)) { + await runtime.ctx.handleSessionCommand(); + } else { + runtime.ctx.showStatus("Usage: /session [info|delete|pin [account]]"); + } runtime.ctx.editor.setText(""); }, }, diff --git a/packages/coding-agent/src/slash-commands/helpers/session-pin.ts b/packages/coding-agent/src/slash-commands/helpers/session-pin.ts new file mode 100644 index 000000000..f947414a5 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/session-pin.ts @@ -0,0 +1,44 @@ +import type { OAuthAccountSummary } from "../../session/auth-storage"; +import { formatActiveAccountLabel } from "./active-oauth-account"; + +/** Stored OAuth account rendered and matched by `/session pin`. */ +export interface SessionPinAccount extends OAuthAccountSummary { + label: string; +} + +/** Add stable user-facing labels to provider account summaries. */ +export function toSessionPinAccounts(accounts: readonly OAuthAccountSummary[]): SessionPinAccount[] { + return accounts.map(account => { + const enterpriseUrl = account.enterpriseUrl?.trim(); + return { + ...account, + label: (formatActiveAccountLabel(account) ?? enterpriseUrl) || `OAuth credential #${account.credentialId}`, + }; + }); +} + +/** Match a `/session pin` selector by 1-based position or exact account identity. */ +export function matchSessionPinAccounts(accounts: readonly SessionPinAccount[], selector: string): SessionPinAccount[] { + const wanted = selector.trim().toLowerCase(); + if (!wanted) return []; + if (wanted === "active") return accounts.filter(account => account.active); + + if (/^\d+$/.test(wanted)) { + const position = Number(wanted) - 1; + const positioned = accounts.find(account => account.position === position); + if (positioned) return [positioned]; + } + + return accounts.filter(account => + [ + account.label, + account.email, + account.accountId, + account.projectId, + account.enterpriseUrl, + account.orgId, + account.orgName, + `OAuth credential #${account.credentialId}`, + ].some(value => value?.trim().toLowerCase() === wanted), + ); +} diff --git a/packages/coding-agent/test/modes/components/session-account-selector.test.ts b/packages/coding-agent/test/modes/components/session-account-selector.test.ts new file mode 100644 index 000000000..fecf94366 --- /dev/null +++ b/packages/coding-agent/test/modes/components/session-account-selector.test.ts @@ -0,0 +1,53 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { SessionAccountSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-account-selector"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { toSessionPinAccounts } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/session-pin"; + +beforeAll(async () => { + await initTheme(); +}); + +const accounts = toSessionPinAccounts([ + { position: 0, credentialId: 11, email: "first@example.com", active: false }, + { position: 1, credentialId: 12, email: "second@example.com", active: true }, +]); + +describe("SessionAccountSelectorComponent", () => { + it("handles navigation, selection, Escape, and Ctrl+C while focused", () => { + const selected: number[] = []; + let cancellations = 0; + const component = new SessionAccountSelectorComponent( + "Anthropic", + accounts, + account => selected.push(account.credentialId), + () => { + cancellations += 1; + }, + ); + + component.handleInput("\x1b[A"); + component.handleInput("\n"); + expect(selected).toEqual([11]); + + const escapeComponent = new SessionAccountSelectorComponent( + "Anthropic", + accounts, + () => {}, + () => { + cancellations += 1; + }, + ); + escapeComponent.handleInput("\x1b"); + + const ctrlCComponent = new SessionAccountSelectorComponent( + "Anthropic", + accounts, + () => {}, + () => { + cancellations += 1; + }, + ); + ctrlCComponent.handleInput("\x03"); + expect(cancellations).toBe(2); + }); +}); diff --git a/packages/coding-agent/test/slash-commands/session.test.ts b/packages/coding-agent/test/slash-commands/session.test.ts index 86c6821d4..260a714da 100644 --- a/packages/coding-agent/test/slash-commands/session.test.ts +++ b/packages/coding-agent/test/slash-commands/session.test.ts @@ -5,6 +5,7 @@ import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-comm function createRuntimeHarness(options?: { handleSessionCommand?: InteractiveModeContext["handleSessionCommand"]; handleSessionDeleteCommand?: InteractiveModeContext["handleSessionDeleteCommand"]; + showSessionPinSelector?: InteractiveModeContext["showSessionPinSelector"]; }) { const setText = vi.fn(); const handleSessionCommand = @@ -17,16 +18,23 @@ function createRuntimeHarness(options?: { vi.fn(async () => { return; }); + const showSessionPinSelector = + options?.showSessionPinSelector ?? + vi.fn(async () => { + return; + }); return { setText, handleSessionCommand, handleSessionDeleteCommand, + showSessionPinSelector, runtime: { ctx: { editor: { setText } as unknown as InteractiveModeContext["editor"], handleSessionCommand, handleSessionDeleteCommand, + showSessionPinSelector, } as InteractiveModeContext, }, }; @@ -58,6 +66,26 @@ describe("/session slash command", () => { expect(harness.setText).toHaveBeenCalledWith(""); }); + it("awaits the session account picker", async () => { + const deferred = Promise.withResolvers(); + const showSessionPinSelector = vi.fn(() => deferred.promise); + const harness = createRuntimeHarness({ showSessionPinSelector }); + let settled = false; + const execution = executeBuiltinSlashCommand("/session pin", harness.runtime).then(result => { + settled = true; + return result; + }); + + await Promise.resolve(); + expect(showSessionPinSelector).toHaveBeenCalledTimes(1); + expect(harness.setText).not.toHaveBeenCalled(); + expect(settled).toBe(false); + + deferred.resolve(); + expect(await execution).toBe(true); + expect(harness.setText).toHaveBeenCalledWith(""); + }); + it("propagates session info failures through executeBuiltinSlashCommand", async () => { const infoError = new Error("info failed"); const handleSessionCommand = vi.fn(async () => {