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.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, SessionPinAccount>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const session = this.ctx.session;
|
||||
this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
|
||||
|
||||
@@ -4639,6 +4639,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#selectorController.showOAuthSelector(mode, providerId);
|
||||
}
|
||||
|
||||
showSessionPinSelector(): Promise<void> {
|
||||
return this.#selectorController.showSessionPinSelector();
|
||||
}
|
||||
|
||||
showResetUsageSelector(): Promise<void> {
|
||||
return this.#selectorController.showResetUsageSelector();
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ export interface InteractiveModeContext {
|
||||
handleResumeSession(sessionPath: string): Promise<void>;
|
||||
handleSessionDeleteCommand(): Promise<void>;
|
||||
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
|
||||
showSessionPinSelector(): Promise<void>;
|
||||
showResetUsageSelector(): Promise<void>;
|
||||
showProviderSetup(): Promise<void>;
|
||||
showHookConfirm(title: string, message: string): Promise<boolean>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SessionOAuthAccountList | undefined> {
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
AuthStorageOptions,
|
||||
CredentialOrigin,
|
||||
CredentialOriginKind,
|
||||
OAuthAccountSummary,
|
||||
OAuthAccountIdentity,
|
||||
OAuthCredential,
|
||||
ResetCreditAccountStatus,
|
||||
|
||||
@@ -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<void> {
|
||||
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 <number|email|account id>`.");
|
||||
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<SlashCommandSpec> = [
|
||||
{
|
||||
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<SlashCommandSpec> = [
|
||||
);
|
||||
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<SlashCommandSpec> = [
|
||||
);
|
||||
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("");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>();
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user