Add global web auth flows

This commit is contained in:
Federico Jaramillo Martinez
2026-05-16 22:44:24 +02:00
parent 9b1b1bbdba
commit 6a8f8b6ccf
25 changed files with 1583 additions and 10 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
+15
View File
@@ -5,6 +5,7 @@ import {
parseAborted,
parseAccepted,
parseArchived,
parseAuthProvidersResponse,
parseClosed,
parseCommandResult,
parseDetached,
@@ -15,6 +16,7 @@ import {
parseGitStatusResponse,
parseMessagePage,
parseModelSelectionResponse,
parseOAuthFlowState,
parseProject,
parseRestored,
parseSessionInfo,
@@ -61,6 +63,19 @@ export const sessionsApi = {
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string) => request(`/api/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key" }) => {
const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode);
if (options?.authType !== undefined) params.set("authType", options.authType);
const query = params.toString();
return request(`/api/auth/providers${query === "" ? "" : `?${query}`}`, parseAuthProvidersResponse);
},
saveApiKey: (providerId: string, key: string) => request("/api/auth/api-key", parseAccepted, { method: "POST", body: JSON.stringify({ providerId, key }) }),
logoutProvider: (providerId: string) => request("/api/auth/logout", parseAccepted, { method: "POST", body: JSON.stringify({ providerId }) }),
startOAuthLogin: (providerId: string) => request("/api/auth/oauth", parseOAuthFlowState, { method: "POST", body: JSON.stringify({ providerId }) }),
oauthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}`, parseOAuthFlowState),
respondOAuthFlow: (flowId: string, requestId: string, value: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/respond`, parseOAuthFlowState, { method: "POST", body: JSON.stringify({ requestId, value }) }),
cancelOAuthFlow: (flowId: string) => request(`/api/auth/oauth/${encodeURIComponent(flowId)}/cancel`, parseOAuthFlowState, { method: "POST" }),
};
export const terminalsApi = {
+71 -1
View File
@@ -1,4 +1,4 @@
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -152,6 +152,76 @@ export function parseThinkingLevelsResponse(value: unknown): ThinkingLevelsRespo
return { levels: arrayOf(parseThinkingLevel)(record["levels"]) };
}
function parseAuthType(value: unknown): AuthType {
if (value !== "oauth" && value !== "api_key") throw new Error("Invalid auth type");
return value;
}
function parseAuthStatusSource(value: unknown): AuthStatusSource {
if (value !== "stored" && value !== "runtime" && value !== "environment" && value !== "fallback" && value !== "models_json_key" && value !== "models_json_command") throw new Error("Invalid auth status source");
return value;
}
function parseAuthProviderStatus(value: unknown): AuthProviderStatus {
const record = requireRecord(value);
const source = record["source"] === undefined ? undefined : parseAuthStatusSource(record["source"]);
return { configured: requireBoolean(record, "configured"), ...optionalField("source", source), ...optionalField("label", optionalString(record, "label")) };
}
function parseAuthProviderOption(value: unknown): AuthProviderOption {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), authType: parseAuthType(record["authType"]), status: parseAuthProviderStatus(record["status"]) };
}
export function parseAuthProvidersResponse(value: unknown): AuthProvidersResponse {
const record = requireRecord(value);
return { providers: arrayOf(parseAuthProviderOption)(record["providers"]) };
}
export function parseOAuthFlowState(value: unknown): OAuthFlowState {
const record = requireRecord(value);
const flow = {
flowId: requireString(record, "flowId"),
providerId: requireString(record, "providerId"),
providerName: requireString(record, "providerName"),
status: parseOAuthFlowStatus(record["status"]),
progress: arrayOf((item) => {
if (typeof item !== "string") throw new Error("Expected progress item string");
return item;
})(record["progress"]),
...optionalField("error", optionalString(record, "error")),
...optionalField("auth", optionalOAuthAuth(record["auth"])),
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
...optionalField("select", optionalOAuthSelect(record["select"])),
};
return flow;
}
function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
if (value !== "running" && value !== "complete" && value !== "error" && value !== "cancelled") throw new Error("Invalid OAuth flow status");
return value;
}
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) };
}
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind");
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) };
}
function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
+10 -1
View File
@@ -1,4 +1,4 @@
import type { CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types";
@@ -21,6 +21,7 @@ export interface AppState {
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined;
authDialog: AuthDialogState | undefined;
actionPaletteOpen: boolean;
projectDialogOpen: boolean;
workspaceTool: QualifiedContributionId;
@@ -39,6 +40,13 @@ export interface AppState {
error: string;
}
export type AuthDialogState =
| { step: "method" }
| { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] }
| { step: "apiKey"; provider: AuthProviderOption; value: string; saving?: boolean; error?: string }
| { step: "oauth"; flow: OAuthFlowState; responding?: boolean; inputValue?: string; error?: string }
| { step: "logout"; providers: AuthProviderOption[] };
export function initialAppState(): AppState {
return {
projects: [],
@@ -59,6 +67,7 @@ export function initialAppState(): AppState {
commandDialog: undefined,
modelDialog: undefined,
thinkingDialog: undefined,
authDialog: undefined,
actionPaletteOpen: false,
projectDialogOpen: false,
workspaceTool: "core:workspace.files",
+187
View File
@@ -0,0 +1,187 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import type { AuthDialogState } from "../appState";
import type { AuthProviderOption } from "../api";
import { commandPickerStyles } from "./shared";
@customElement("auth-dialog")
export class AuthDialog extends LitElement {
@property({ attribute: false }) state?: AuthDialogState;
@property({ attribute: false }) onChooseMethod?: (authType: "oauth" | "api_key") => void;
@property({ attribute: false }) onSelectProvider?: (providerId: string, authType: "oauth" | "api_key") => void;
@property({ attribute: false }) onApiKeyInput?: (value: string) => void;
@property({ attribute: false }) onSaveApiKey?: () => void;
@property({ attribute: false }) onLogoutProvider?: (providerId: string) => void;
@property({ attribute: false }) onOAuthInput?: (value: string) => void;
@property({ attribute: false }) onOAuthRespond?: (value?: string) => void;
@property({ attribute: false }) onOAuthCancel?: () => void;
@property({ attribute: false }) onCancel?: () => void;
@query("input") private input?: HTMLInputElement;
private lastFocusedInputKey: string | undefined;
override render() {
const state = this.state;
if (state === undefined) return null;
return html`
<div class="backdrop" @mousedown=${() => { this.cancel(); }}>
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<strong>${this.dialogTitle(state)}</strong>
<button title="Close" @click=${() => { this.cancel(); }}>×</button>
</header>
${this.renderBody(state)}
</section>
</div>
`;
}
protected override updated(): void {
this.focusInputIfNeeded();
}
private dialogTitle(state: AuthDialogState): string {
switch (state.step) {
case "method": return "Configure provider authentication";
case "providers": return state.authType === undefined ? "Select provider authentication" : state.authType === "oauth" ? "Select subscription provider" : "Select API key provider";
case "apiKey": return `API key for ${state.provider.name}`;
case "oauth": return `Login to ${state.flow.providerName}`;
case "logout": return "Remove stored provider authentication";
}
}
private renderBody(state: AuthDialogState) {
switch (state.step) {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html`
<div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
</div>
`;
case "oauth": return this.renderOAuth(state);
case "logout": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No stored credentials. Environment variables and models.json settings are unchanged.</div>` : state.providers.map((provider) => html`
<button @click=${() => { this.onLogoutProvider?.(provider.id); }}><span>${provider.name}</span><small>${provider.id} · ${authTypeLabel(provider.authType)}</small></button>
`)}</div>`;
}
}
private renderProviderButton(provider: AuthProviderOption) {
return html`
<button @click=${() => { this.onSelectProvider?.(provider.id, provider.authType); }}>
<span>${provider.name}${provider.status.source !== undefined ? html` <em>${statusLabel(provider)}</em>` : null}</span>
<small>${provider.id} · ${authTypeLabel(provider.authType)}</small>
</button>
`;
}
private renderOAuth(state: Extract<AuthDialogState, { step: "oauth" }>) {
const flow = state.flow;
const prompt = flow.prompt;
const select = flow.select;
return html`
<div class="form">
${flow.auth !== undefined ? html`
<p>Open this authorization link:</p>
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p>
${flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
` : html`<p>Starting login flow…</p>`}
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
${prompt !== undefined ? html`
<label>${prompt.message}</label>
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
` : null}
${select !== undefined ? html`
<p>${select.message}</p>
<div class="inline-options">${select.options.map((option) => html`<button @click=${() => { this.onOAuthRespond?.(option.value); }}>${option.label}</button>`)}</div>
` : null}
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null}
${prompt === undefined && select === undefined && flow.status === "running" ? html`<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button></div>` : null}
</div>
`;
}
private focusInputIfNeeded(): void {
const key = focusKey(this.state);
if (key === undefined) {
this.lastFocusedInputKey = undefined;
return;
}
if (key === this.lastFocusedInputKey) return;
this.lastFocusedInputKey = key;
this.input?.focus();
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
event.preventDefault();
this.cancel();
return;
}
if (event.key !== "Enter") return;
const state = this.state;
if (state?.step === "apiKey") {
event.preventDefault();
this.onSaveApiKey?.();
} else if (state?.step === "oauth" && state.flow.prompt !== undefined) {
event.preventDefault();
this.onOAuthRespond?.();
}
}
private cancel(): void {
const state = this.state;
if (state?.step === "oauth") this.onOAuthCancel?.();
else this.onCancel?.();
}
static override styles = [commandPickerStyles, css`
.form { display: grid; gap: 12px; padding: 14px; overflow: auto; }
.form p { margin: 0; color: #c9d1d9; overflow-wrap: anywhere; }
.form a { color: #58a6ff; overflow-wrap: anywhere; }
.form code { border: 1px solid #30363d; border-radius: 4px; background: #161b22; padding: 1px 4px; }
label { color: #8b949e; }
.actions { display: flex; justify-content: flex-end; gap: 8px; }
.actions button, .inline-options button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; }
.actions button.primary { border-color: #238636; background: #0f2a16; color: #3fb950; }
.actions button:disabled { opacity: .6; cursor: wait; }
.warning { color: #d29922; }
.error-text { color: #ff7b72; }
.progress { margin: 0; padding-left: 18px; color: #8b949e; }
.inline-options { display: grid; gap: 8px; }
em { color: #3fb950; font-style: normal; font-size: 12px; }
`];
}
function authTypeLabel(authType: "oauth" | "api_key"): string {
return authType === "oauth" ? "subscription" : "API key";
}
function focusKey(state: AuthDialogState | undefined): string | undefined {
if (state?.step === "apiKey") return `api-key:${state.provider.authType}:${state.provider.id}`;
if (state?.step === "oauth" && state.flow.prompt !== undefined) return `oauth:${state.flow.flowId}:${state.flow.prompt.requestId}`;
return undefined;
}
function statusLabel(provider: AuthProviderOption): string {
if (provider.status.source === undefined) return "";
switch (provider.status.source) {
case "stored": return "✓ configured";
case "environment": return `✓ env${provider.status.label === undefined ? "" : `: ${provider.status.label}`}`;
case "runtime": return "✓ runtime";
case "fallback": return "✓ custom key";
case "models_json_key": return "✓ models.json key";
case "models_json_command": return "✓ models.json command";
default: return "";
}
}
+17 -1
View File
@@ -3,6 +3,7 @@ import { customElement, query, state } from "lit/decorators.js";
import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState";
import { AuthController } from "../controllers/authController";
import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { ProjectController } from "../controllers/projectController";
@@ -26,6 +27,7 @@ import type { PromptEditor } from "./PromptEditor";
import "./StatusBar";
import "./CommandPicker";
import "./ActionPalette";
import "./AuthDialog";
import "./ProjectDialog";
import "./WorkspacePanel";
import { appStyles } from "./shared";
@@ -41,6 +43,11 @@ export class PiWebApp extends LitElement {
(patch) => { this.setState(patch); },
() => { this.updateUrl(); },
);
private readonly auth = new AuthController(
() => this.state,
(patch) => { this.setState(patch); },
(status) => { this.sessions.applySessionStatus(status); },
);
private readonly workspaces = new WorkspaceController(
() => this.state,
(patch) => { this.setState(patch); },
@@ -103,6 +110,7 @@ export class PiWebApp extends LitElement {
window.removeEventListener("keydown", this.onKeyDown);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
this.keyboard.reset();
this.auth.dispose();
this.sessions.dispose();
this.realtime.close();
this.git.dispose();
@@ -327,6 +335,8 @@ export class PiWebApp extends LitElement {
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
focusPrompt: () => { this.promptEditor?.focusInput(); },
addProject: () => { this.setState({ projectDialogOpen: true }); },
configureAuth: () => this.auth.openLogin(),
logoutAuth: () => this.auth.openLogout(),
selectMainView: (view) => { this.selectMainView(view); },
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
refreshFiles: () => this.files.refreshFiles(),
@@ -384,6 +394,11 @@ export class PiWebApp extends LitElement {
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
}
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp"): void {
if (streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
void this.sessions.send(text, streamingBehavior);
}
override render() {
const state = this.state;
return html`
@@ -401,11 +416,12 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}></auth-dialog>` : null}
` : html`<div class="empty">Select or start a session.</div>`}
</main>
${this.renderWorkspacePanel()}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { api as defaultApi, type AuthProviderOption, type OAuthFlowState } from "../api";
import { initialAppState, type AppState } from "../appState";
import { AuthController, parseAuthSlashCommand } from "./authController";
describe("parseAuthSlashCommand", () => {
it("parses login and logout commands", () => {
expect(parseAuthSlashCommand("/login")).toEqual({ command: "login" });
expect(parseAuthSlashCommand("/logout")).toEqual({ command: "logout" });
});
it("parses provider arguments", () => {
expect(parseAuthSlashCommand("/login openai")).toEqual({ command: "login", providerId: "openai" });
expect(parseAuthSlashCommand("/logout openai-codex ")).toEqual({ command: "logout", providerId: "openai-codex" });
});
it("ignores non-auth commands and extra arguments", () => {
expect(parseAuthSlashCommand("/model")).toBeUndefined();
expect(parseAuthSlashCommand("hello /login")).toBeUndefined();
expect(parseAuthSlashCommand("/login openai extra")).toBeUndefined();
});
});
describe("AuthController", () => {
it("uses auth type to disambiguate provider options with the same id", async () => {
const providers = [authProvider("anthropic", "oauth"), authProvider("anthropic", "api_key")];
const { controller, getState } = createController({ authDialog: { step: "providers", mode: "login", providers } });
await controller.selectLoginProvider("anthropic", "api_key");
expect(getState().authDialog).toMatchObject({ step: "apiKey", provider: { id: "anthropic", authType: "api_key" } });
});
it("keeps OAuth prompt input and submit state across poll refreshes for the same request", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "https://callback", responding: true } },
{ respondOAuthFlow: () => Promise.resolve(oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" }, progress: ["Still waiting"] })) },
);
await controller.respondOAuth();
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
});
});
function createController(statePatch: Partial<AppState>, apiPatch: Partial<typeof defaultApi> = {}) {
let state: AppState = { ...initialAppState(), ...statePatch };
const api = { ...defaultApi, ...apiPatch };
const controller = new AuthController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
{ api },
);
return { controller, getState: () => state };
}
function authProvider(id: string, authType: "oauth" | "api_key"): AuthProviderOption {
return { id, authType, name: `${id} ${authType}`, status: { configured: false } };
}
function oauthFlow(patch: Partial<OAuthFlowState> = {}): OAuthFlowState {
return {
flowId: "flow-1",
providerId: "anthropic",
providerName: "Anthropic",
status: "running",
progress: [],
...patch,
};
}
@@ -0,0 +1,261 @@
import { api as defaultApi, type AuthProviderOption, type AuthType, type OAuthFlowState, type SessionStatus } from "../api";
import type { GetState, SetState } from "./types";
export interface AuthControllerDependencies {
api?: typeof defaultApi;
pollIntervalMs?: number;
}
export class AuthController {
private readonly api: typeof defaultApi;
private readonly pollIntervalMs: number;
private pollTimer: number | undefined;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
private readonly applyStatus: (status: SessionStatus) => void,
deps: AuthControllerDependencies = {},
) {
this.api = deps.api ?? defaultApi;
this.pollIntervalMs = deps.pollIntervalMs ?? 1000;
}
dispose(): void {
this.stopPolling();
}
handleSlashCommand(text: string): boolean {
const parsed = parseAuthSlashCommand(text);
if (parsed === undefined) return false;
if (parsed.command === "login") void this.openLogin(parsed.providerId);
else void this.openLogout(parsed.providerId);
return true;
}
async openLogin(providerId?: string): Promise<void> {
if (providerId !== undefined && providerId !== "") {
await this.openLoginProvider(providerId);
return;
}
this.setState({ authDialog: { step: "method" } });
}
async chooseLoginMethod(authType: AuthType): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "login", authType });
this.setState({ authDialog: { step: "providers", mode: "login", authType, providers } });
} catch (error) {
this.setState({ error: String(error) });
}
}
async selectLoginProvider(providerId: string, authType?: AuthType): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "providers") return;
const provider = dialog.providers.find((candidate) => candidate.id === providerId && (authType === undefined || candidate.authType === authType));
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
}
updateApiKey(value: string): void {
const dialog = this.getState().authDialog;
if (dialog?.step !== "apiKey") return;
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, value } });
}
async saveApiKey(): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "apiKey") return;
const key = dialog.value.trim();
if (key === "") {
this.setState({ authDialog: { ...dialog, error: "API key is required" } });
return;
}
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, saving: true } });
try {
await this.api.saveApiKey(dialog.provider.id, key);
this.closeDialog();
void this.refreshStatus();
} catch (error) {
this.setState({ authDialog: { ...dialog, saving: false, error: String(error) } });
}
}
async openLogout(providerId?: string): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "logout" });
if (providerId !== undefined && providerId !== "") {
const provider = providers.find((candidate) => candidate.id === providerId);
if (provider !== undefined) await this.logoutProvider(provider.id);
else this.setState({ error: `No stored credentials for ${providerId}` });
return;
}
this.setState({ authDialog: { step: "logout", providers } });
} catch (error) {
this.setState({ error: String(error) });
}
}
async logoutProvider(providerId: string): Promise<void> {
try {
await this.api.logoutProvider(providerId);
this.closeDialog();
void this.refreshStatus();
} catch (error) {
this.setState({ error: String(error) });
}
}
updateOAuthInput(value: string): void {
const dialog = this.getState().authDialog;
if (dialog?.step !== "oauth") return;
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, inputValue: value } });
}
async respondOAuth(value?: string): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "oauth") return;
const request = dialog.flow.prompt ?? dialog.flow.select;
if (request === undefined) return;
const responseValue = value ?? dialog.inputValue ?? "";
const clean = { ...dialog };
delete clean.error;
this.setState({ authDialog: { ...clean, responding: true } });
try {
const flow = await this.api.respondOAuthFlow(dialog.flow.flowId, request.requestId, responseValue);
this.updateOAuthFlow(flow);
} catch (error) {
this.setState({ authDialog: { ...dialog, responding: false, error: String(error) } });
}
}
async cancelOAuth(): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "oauth") {
this.closeDialog();
return;
}
this.stopPolling();
try {
await this.api.cancelOAuthFlow(dialog.flow.flowId);
} catch {
// Best-effort cancel. The dialog closes either way.
}
this.closeDialog();
}
closeDialog(): void {
this.stopPolling();
this.setState({ authDialog: undefined });
}
private async openLoginProvider(providerId: string): Promise<void> {
try {
const { providers } = await this.api.authProviders({ mode: "login" });
const exact = providers.filter((provider) => provider.id === providerId);
if (exact.length === 0) {
this.setState({ error: `Auth provider not found: ${providerId}` });
return;
}
if (exact.length > 1) {
this.setState({ authDialog: { step: "providers", mode: "login", providers: exact } });
return;
}
const provider = exact[0];
if (provider === undefined) return;
if (provider.authType === "oauth") await this.startOAuth(provider);
else this.setState({ authDialog: { step: "apiKey", provider, value: "" } });
} catch (error) {
this.setState({ error: String(error) });
}
}
private async startOAuth(provider: AuthProviderOption): Promise<void> {
try {
const flow = await this.api.startOAuthLogin(provider.id);
this.updateOAuthFlow(flow);
this.startPolling(flow.flowId);
} catch (error) {
this.setState({ error: String(error) });
}
}
private updateOAuthFlow(flow: OAuthFlowState): void {
if (flow.status === "complete") {
this.stopPolling();
this.closeDialog();
void this.refreshStatus();
return;
}
if (flow.status === "error" || flow.status === "cancelled") this.stopPolling();
const existing = this.getState().authDialog;
const previousInput = existing?.step === "oauth" && existing.flow.flowId === flow.flowId ? existing.inputValue ?? "" : "";
const previousRequestId = existing?.step === "oauth" ? existing.flow.prompt?.requestId ?? existing.flow.select?.requestId : undefined;
const newRequestId = flow.prompt?.requestId ?? flow.select?.requestId;
const sameRequest = previousRequestId !== undefined && previousRequestId === newRequestId;
const inputValue = sameRequest ? previousInput : "";
const responding = sameRequest && existing?.step === "oauth" ? existing.responding === true : false;
this.setState({ authDialog: { step: "oauth", flow, inputValue, responding } });
}
private startPolling(flowId: string): void {
this.stopPolling();
this.pollTimer = window.setInterval(() => { void this.poll(flowId); }, this.pollIntervalMs);
}
private stopPolling(): void {
if (this.pollTimer === undefined) return;
window.clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
private async poll(flowId: string): Promise<void> {
const dialog = this.getState().authDialog;
if (dialog?.step !== "oauth" || dialog.flow.flowId !== flowId) {
this.stopPolling();
return;
}
try {
this.updateOAuthFlow(await this.api.oauthFlow(flowId));
} catch (error) {
this.stopPolling();
this.setState({ authDialog: { ...dialog, error: String(error) } });
}
}
private async refreshStatus(): Promise<void> {
const sessionId = this.sessionId();
if (sessionId === undefined) return;
try {
this.applyStatus(await this.api.status(sessionId));
} catch {
// Status refresh is opportunistic after login completes.
}
}
private sessionId(): string | undefined {
const session = this.getState().selectedSession;
if (session === undefined || session.archived === true) return undefined;
return session.id;
}
}
export function parseAuthSlashCommand(text: string): { command: "login" | "logout"; providerId?: string } | undefined {
const trimmed = text.trim();
const match = /^\/(login|logout)(?:\s+(\S+))?\s*$/u.exec(trimmed);
if (match === null) return undefined;
const command = match[1];
if (command !== "login" && command !== "logout") return undefined;
const providerId = match[2];
return providerId === undefined || providerId === "" ? { command } : { command, providerId };
}
export type { AuthDialogState } from "../appState";
@@ -199,6 +199,10 @@ export class SessionController {
this.setState({ commandDialog: undefined });
}
applySessionStatus(status: SessionStatus): void {
this.applyStatus(status);
}
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
if (isCachedNewSessionInfo(session)) {
+14
View File
@@ -25,6 +25,20 @@ export function createCoreActions(): PluginAction[] {
group: "Project",
run: (context) => context.addProject(),
},
{
id: "auth.login",
title: "Configure Provider Authentication",
description: "Run /login without tying authentication to a session",
group: "General",
run: (context) => context.configureAuth(),
},
{
id: "auth.logout",
title: "Remove Provider Authentication",
description: "Run /logout for stored pi credentials",
group: "General",
run: (context) => context.logoutAuth(),
},
{
id: "view.chat",
title: "Go to Chat",
+2
View File
@@ -12,6 +12,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
openActionPalette: vi.fn(() => { calls.push("openActionPalette"); }),
focusPrompt: vi.fn(() => { calls.push("focusPrompt"); }),
addProject: vi.fn(() => { calls.push("addProject"); }),
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }),
+2
View File
@@ -28,6 +28,8 @@ export interface PluginRuntimeContext {
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
selectMainView: (view: AppState["mainView"]) => void;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
refreshFiles: () => void | Promise<void>;