@oh-my-pi/pi-coding-agent 12.1.1 → 12.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/examples/sdk/11-sessions.ts +1 -1
  3. package/package.json +7 -7
  4. package/src/capability/index.ts +2 -1
  5. package/src/capability/types.ts +1 -1
  6. package/src/cli/file-processor.ts +2 -1
  7. package/src/cli/shell-cli.ts +2 -2
  8. package/src/commit/agentic/index.ts +2 -1
  9. package/src/commit/pipeline.ts +2 -1
  10. package/src/config/prompt-templates.ts +3 -3
  11. package/src/config/settings-schema.ts +11 -0
  12. package/src/config/settings.ts +2 -2
  13. package/src/config.ts +6 -6
  14. package/src/debug/system-info.ts +2 -2
  15. package/src/extensibility/custom-commands/loader.ts +5 -5
  16. package/src/extensibility/plugins/installer.ts +2 -2
  17. package/src/extensibility/plugins/manager.ts +2 -1
  18. package/src/extensibility/skills.ts +3 -2
  19. package/src/extensibility/slash-commands.ts +1 -1
  20. package/src/ipy/executor.ts +2 -2
  21. package/src/ipy/modules.ts +3 -3
  22. package/src/main.ts +9 -7
  23. package/src/mcp/transports/stdio.ts +2 -1
  24. package/src/modes/components/footer.ts +3 -2
  25. package/src/modes/components/oauth-selector.ts +96 -21
  26. package/src/modes/components/status-line/segments.ts +2 -1
  27. package/src/modes/components/status-line.ts +2 -1
  28. package/src/modes/components/tool-execution.ts +2 -1
  29. package/src/modes/controllers/command-controller.ts +60 -2
  30. package/src/modes/controllers/mcp-command-controller.ts +8 -8
  31. package/src/modes/controllers/selector-controller.ts +35 -11
  32. package/src/modes/interactive-mode.ts +2 -2
  33. package/src/sdk.ts +37 -11
  34. package/src/session/agent-session.ts +12 -0
  35. package/src/session/session-manager.ts +7 -4
  36. package/src/system-prompt.ts +41 -28
  37. package/src/tools/bash-normalize.ts +2 -2
  38. package/src/tools/bash.ts +2 -1
  39. package/src/tools/fetch.ts +3 -3
  40. package/src/tools/python.ts +2 -1
@@ -3,9 +3,8 @@ import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import type { AuthStorage } from "../../session/auth-storage";
5
5
  import { DynamicBorder } from "./dynamic-border";
6
-
7
6
  /**
8
- * Component that renders an OAuth provider selector
7
+ * Component that renders an OAuth provider selector.
9
8
  */
10
9
  export class OAuthSelectorComponent extends Container {
11
10
  #listContainer: Container;
@@ -16,62 +15,139 @@ export class OAuthSelectorComponent extends Container {
16
15
  #onSelectCallback: (providerId: string) => void;
17
16
  #onCancelCallback: () => void;
18
17
  #statusMessage: string | undefined;
19
-
18
+ #validateAuthCallback?: (providerId: string) => Promise<boolean>;
19
+ #requestRenderCallback?: () => void;
20
+ #authState: Map<string, "checking" | "valid" | "invalid"> = new Map();
21
+ #spinnerFrame: number = 0;
22
+ #spinnerInterval?: NodeJS.Timeout;
23
+ #validationGeneration: number = 0;
20
24
  constructor(
21
25
  mode: "login" | "logout",
22
26
  authStorage: AuthStorage,
23
27
  onSelect: (providerId: string) => void,
24
28
  onCancel: () => void,
29
+ options?: {
30
+ validateAuth?: (providerId: string) => Promise<boolean>;
31
+ requestRender?: () => void;
32
+ },
25
33
  ) {
26
34
  super();
27
-
28
35
  this.#mode = mode;
29
36
  this.#authStorage = authStorage;
30
37
  this.#onSelectCallback = onSelect;
31
38
  this.#onCancelCallback = onCancel;
32
-
39
+ this.#validateAuthCallback = options?.validateAuth;
40
+ this.#requestRenderCallback = options?.requestRender;
33
41
  // Load all OAuth providers
34
42
  this.#loadProviders();
35
-
36
- // Add top border
37
43
  this.addChild(new DynamicBorder());
38
44
  this.addChild(new Spacer(1));
39
-
40
45
  // Add title
41
46
  const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
42
47
  this.addChild(new TruncatedText(theme.bold(title)));
43
48
  this.addChild(new Spacer(1));
44
-
45
49
  // Create list container
46
50
  this.#listContainer = new Container();
47
51
  this.addChild(this.#listContainer);
48
-
49
52
  this.addChild(new Spacer(1));
50
-
51
53
  // Add bottom border
52
54
  this.addChild(new DynamicBorder());
53
-
54
55
  // Initial render
55
56
  this.#updateList();
57
+ this.#startValidation();
56
58
  }
57
59
 
60
+ stopValidation(): void {
61
+ this.#validationGeneration += 1;
62
+ this.#stopSpinner();
63
+ }
58
64
  #loadProviders(): void {
59
65
  this.#allProviders = getOAuthProviders();
60
66
  }
61
67
 
68
+ #startValidation(): void {
69
+ if (!this.#validateAuthCallback) return;
70
+ const generation = this.#validationGeneration + 1;
71
+ this.#validationGeneration = generation;
72
+
73
+ let pending = 0;
74
+ for (const provider of this.#allProviders) {
75
+ if (!this.#authStorage.hasAuth(provider.id)) {
76
+ this.#authState.delete(provider.id);
77
+ continue;
78
+ }
79
+ this.#authState.set(provider.id, "checking");
80
+ pending += 1;
81
+ void this.#validateProvider(provider.id, generation);
82
+ }
83
+
84
+ if (pending > 0) {
85
+ this.#startSpinner();
86
+ this.#updateList();
87
+ this.#requestRenderCallback?.();
88
+ }
89
+ }
90
+
91
+ async #validateProvider(providerId: string, generation: number): Promise<void> {
92
+ if (!this.#validateAuthCallback) return;
93
+ let isValid = false;
94
+ try {
95
+ isValid = await this.#validateAuthCallback(providerId);
96
+ } catch {
97
+ isValid = false;
98
+ }
99
+
100
+ if (generation !== this.#validationGeneration) return;
101
+ this.#authState.set(providerId, isValid ? "valid" : "invalid");
102
+ if (![...this.#authState.values()].includes("checking")) {
103
+ this.#stopSpinner();
104
+ }
105
+ this.#updateList();
106
+ this.#requestRenderCallback?.();
107
+ }
108
+
109
+ #startSpinner(): void {
110
+ if (this.#spinnerInterval) return;
111
+ this.#spinnerInterval = setInterval(() => {
112
+ const frameCount = theme.spinnerFrames.length;
113
+ if (frameCount > 0) {
114
+ this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
115
+ }
116
+ this.#updateList();
117
+ this.#requestRenderCallback?.();
118
+ }, 80);
119
+ }
120
+
121
+ #stopSpinner(): void {
122
+ if (this.#spinnerInterval) {
123
+ clearInterval(this.#spinnerInterval);
124
+ this.#spinnerInterval = undefined;
125
+ }
126
+ }
127
+
128
+ #getStatusIndicator(providerId: string): string {
129
+ const state = this.#authState.get(providerId);
130
+ if (state === "checking") {
131
+ const frameCount = theme.spinnerFrames.length;
132
+ const spinner = frameCount > 0 ? theme.spinnerFrames[this.#spinnerFrame % frameCount] : theme.status.pending;
133
+ return theme.fg("warning", ` ${spinner} checking`);
134
+ }
135
+ if (state === "invalid") {
136
+ return theme.fg("error", ` ${theme.status.error} invalid`);
137
+ }
138
+ if (state === "valid") {
139
+ return theme.fg("success", ` ${theme.status.success} logged in`);
140
+ }
141
+ return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
142
+ }
62
143
  #updateList(): void {
63
144
  this.#listContainer.clear();
64
-
65
145
  for (let i = 0; i < this.#allProviders.length; i++) {
66
146
  const provider = this.#allProviders[i];
67
147
  if (!provider) continue;
68
-
69
148
  const isSelected = i === this.#selectedIndex;
70
149
  const isAvailable = provider.available;
71
-
72
- // Check if user is logged in for this provider
73
- const isLoggedIn = this.#authStorage.hasOAuth(provider.id);
74
- const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
150
+ const statusIndicator = this.#getStatusIndicator(provider.id);
75
151
 
76
152
  let line = "";
77
153
  if (isSelected) {
@@ -82,7 +158,6 @@ export class OAuthSelectorComponent extends Container {
82
158
  const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
83
159
  line = text + statusIndicator;
84
160
  }
85
-
86
161
  this.#listContainer.addChild(new TruncatedText(line, 0, 0));
87
162
  }
88
163
 
@@ -92,13 +167,11 @@ export class OAuthSelectorComponent extends Container {
92
167
  this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
93
168
  this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
94
169
  }
95
-
96
170
  if (this.#statusMessage) {
97
171
  this.#listContainer.addChild(new Spacer(1));
98
172
  this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
99
173
  }
100
174
  }
101
-
102
175
  handleInput(keyData: string): void {
103
176
  // Up arrow
104
177
  if (matchesKey(keyData, "up")) {
@@ -121,6 +194,7 @@ export class OAuthSelectorComponent extends Container {
121
194
  const selectedProvider = this.#allProviders[this.#selectedIndex];
122
195
  if (selectedProvider?.available) {
123
196
  this.#statusMessage = undefined;
197
+ this.stopValidation();
124
198
  this.#onSelectCallback(selectedProvider.id);
125
199
  } else if (selectedProvider) {
126
200
  this.#statusMessage = "Provider unavailable in this environment.";
@@ -129,6 +203,7 @@ export class OAuthSelectorComponent extends Container {
129
203
  }
130
204
  // Escape or Ctrl+C
131
205
  else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
206
+ this.stopValidation();
132
207
  this.#onCancelCallback();
133
208
  }
134
209
  }
@@ -1,4 +1,5 @@
1
1
  import * as os from "node:os";
2
+ import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
2
3
  import { theme } from "../../../modes/theme/theme";
3
4
  import { shortenPath } from "../../../tools/render-utils";
4
5
  import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types";
@@ -91,7 +92,7 @@ const pathSegment: StatusLineSegment = {
91
92
  render(ctx) {
92
93
  const opts = ctx.options.path ?? {};
93
94
 
94
- let pwd = process.cwd();
95
+ let pwd = getProjectDir();
95
96
 
96
97
  if (opts.abbreviate !== false) {
97
98
  pwd = shortenPath(pwd);
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
4
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
5
+ import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
5
6
  import { $ } from "bun";
6
7
  import { settings } from "../../config/settings";
7
8
  import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
@@ -41,7 +42,7 @@ function sanitizeStatusText(text: string): string {
41
42
 
42
43
  /** Find the git root directory by walking up from cwd */
43
44
  function findGitHeadPath(): string | null {
44
- let dir = process.cwd();
45
+ let dir = getProjectDir();
45
46
  while (true) {
46
47
  const gitHeadPath = path.join(dir, ".git", "HEAD");
47
48
  if (fs.existsSync(gitHeadPath)) {
@@ -13,6 +13,7 @@ import {
13
13
  type TUI,
14
14
  } from "@oh-my-pi/pi-tui";
15
15
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
16
+ import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
16
17
  import type { Theme } from "../../modes/theme/theme";
17
18
  import { theme } from "../../modes/theme/theme";
18
19
  import {
@@ -129,7 +130,7 @@ export class ToolExecutionComponent extends Container {
129
130
  options: ToolExecutionOptions = {},
130
131
  tool: AgentTool | undefined,
131
132
  ui: TUI,
132
- cwd: string = process.cwd(),
133
+ cwd: string = getProjectDir(),
133
134
  ) {
134
135
  super();
135
136
  this.#toolName = toolName;
@@ -1,10 +1,17 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
4
+ import {
5
+ getEnvApiKey,
6
+ getProviderDetails,
7
+ type ProviderDetails,
8
+ type UsageLimit,
9
+ type UsageReport,
10
+ } from "@oh-my-pi/pi-ai";
5
11
  import { copyToClipboard } from "@oh-my-pi/pi-natives";
6
12
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
7
13
  import { Snowflake } from "@oh-my-pi/pi-utils";
14
+ import { setProjectDir } from "@oh-my-pi/pi-utils/dirs";
8
15
  import { $ } from "bun";
9
16
  import { reset as resetCapabilities } from "../../capability";
10
17
  import { loadCustomShare } from "../../export/custom-share";
@@ -16,6 +23,7 @@ import { DynamicBorder } from "../../modes/components/dynamic-border";
16
23
  import { PythonExecutionComponent } from "../../modes/components/python-execution";
17
24
  import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
18
25
  import type { InteractiveModeContext } from "../../modes/types";
26
+ import type { AuthStorage } from "../../session/auth-storage";
19
27
  import { createCompactionSummaryMessage } from "../../session/messages";
20
28
  import { outputMeta } from "../../tools/output-meta";
21
29
  import { resolveToCwd } from "../../tools/path-utils";
@@ -206,6 +214,25 @@ export class CommandController {
206
214
  let info = `${theme.bold("Session Info")}\n\n`;
207
215
  info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
208
216
  info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
217
+ info += `\n${theme.bold("Provider")}\n`;
218
+ const model = this.ctx.session.model;
219
+ if (!model) {
220
+ info += `${theme.fg("dim", "No model selected")}\n`;
221
+ } else {
222
+ const authMode = resolveProviderAuthMode(this.ctx.session.modelRegistry.authStorage, model.provider);
223
+ const openaiWebsocketSetting = this.ctx.settings.get("providers.openaiWebsockets") ?? "auto";
224
+ const preferOpenAICodexWebsockets =
225
+ openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
226
+ const providerDetails = getProviderDetails({
227
+ model,
228
+ sessionId: stats.sessionId,
229
+ authMode,
230
+ preferWebsockets: preferOpenAICodexWebsockets,
231
+ providerSessionState: this.ctx.session.providerSessionState,
232
+ });
233
+ info += renderProviderSection(providerDetails, theme);
234
+ }
235
+ info += `\n`;
209
236
  info += `${theme.bold("Messages")}\n`;
210
237
  info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
211
238
  info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
@@ -388,6 +415,12 @@ export class CommandController {
388
415
  }
389
416
  this.ctx.statusContainer.clear();
390
417
 
418
+ if (this.ctx.session.isCompacting) {
419
+ this.ctx.session.abortCompaction();
420
+ while (this.ctx.session.isCompacting) {
421
+ await Bun.sleep(10);
422
+ }
423
+ }
391
424
  await this.ctx.session.newSession();
392
425
 
393
426
  this.ctx.statusLine.invalidate();
@@ -460,7 +493,7 @@ export class CommandController {
460
493
  try {
461
494
  await this.ctx.sessionManager.flush();
462
495
  await this.ctx.sessionManager.moveTo(resolvedPath);
463
- process.chdir(resolvedPath);
496
+ setProjectDir(resolvedPath);
464
497
  resetCapabilities();
465
498
  await this.ctx.refreshSlashCommandState(resolvedPath);
466
499
 
@@ -733,6 +766,31 @@ function formatDurationShort(ms: number): string {
733
766
  return `${totalSeconds}s`;
734
767
  }
735
768
 
769
+ function resolveProviderAuthMode(authStorage: AuthStorage, provider: string): string {
770
+ if (authStorage.hasOAuth(provider)) {
771
+ return "oauth";
772
+ }
773
+ if (authStorage.has(provider)) {
774
+ return "api key";
775
+ }
776
+ if (getEnvApiKey(provider)) {
777
+ return "env api key";
778
+ }
779
+ if (authStorage.hasAuth(provider)) {
780
+ return "runtime/fallback";
781
+ }
782
+ return "unknown";
783
+ }
784
+
785
+ export function renderProviderSection(details: ProviderDetails, uiTheme: Pick<typeof theme, "fg">): string {
786
+ const lines: string[] = [];
787
+ lines.push(`${uiTheme.fg("dim", "Name:")} ${details.provider}`);
788
+ for (const field of details.fields) {
789
+ lines.push(`${uiTheme.fg("dim", `${field.label}:`)} ${field.value}`);
790
+ }
791
+ return `${lines.join("\n")}\n`;
792
+ }
793
+
736
794
  function resolveFraction(limit: UsageLimit): number | undefined {
737
795
  const amount = limit.amount;
738
796
  if (amount.usedFraction !== undefined) return amount.usedFraction;
@@ -4,7 +4,7 @@
4
4
  * Handles /mcp subcommands for managing MCP servers.
5
5
  */
6
6
  import { Spacer, Text } from "@oh-my-pi/pi-tui";
7
- import { getMCPConfigPath } from "@oh-my-pi/pi-utils/dirs";
7
+ import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
8
8
  import type { SourceMeta } from "../../capability/types";
9
9
  import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
10
10
  import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
@@ -528,7 +528,7 @@ export class MCPCommandController {
528
528
  if (this.ctx.mcpManager) {
529
529
  resolvedConfig = await this.ctx.mcpManager.prepareConfig(config);
530
530
  } else {
531
- const tempManager = new MCPManager(process.cwd());
531
+ const tempManager = new MCPManager(getProjectDir());
532
532
  tempManager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
533
533
  resolvedConfig = await tempManager.prepareConfig(config);
534
534
  }
@@ -540,7 +540,7 @@ export class MCPCommandController {
540
540
  async #findConfiguredServer(
541
541
  name: string,
542
542
  ): Promise<{ filePath: string; scope: "user" | "project"; config: MCPServerConfig } | null> {
543
- const cwd = process.cwd();
543
+ const cwd = getProjectDir();
544
544
  const userPath = getMCPConfigPath("user", cwd);
545
545
  const projectPath = getMCPConfigPath("project", cwd);
546
546
 
@@ -665,7 +665,7 @@ export class MCPCommandController {
665
665
  async #handleWizardComplete(name: string, config: MCPServerConfig, scope: "user" | "project"): Promise<void> {
666
666
  try {
667
667
  // Determine file path
668
- const cwd = process.cwd();
668
+ const cwd = getProjectDir();
669
669
  const filePath = getMCPConfigPath(scope, cwd);
670
670
 
671
671
  // Add server to config
@@ -747,7 +747,7 @@ export class MCPCommandController {
747
747
  */
748
748
  async #handleList(): Promise<void> {
749
749
  try {
750
- const cwd = process.cwd();
750
+ const cwd = getProjectDir();
751
751
 
752
752
  // Load from both user and project configs
753
753
  const userPath = getMCPConfigPath("user", cwd);
@@ -913,7 +913,7 @@ export class MCPCommandController {
913
913
  }
914
914
 
915
915
  try {
916
- const cwd = process.cwd();
916
+ const cwd = getProjectDir();
917
917
  const userPath = getMCPConfigPath("user", cwd);
918
918
  const projectPath = getMCPConfigPath("project", cwd);
919
919
  const filePath = scope === "user" ? userPath : projectPath;
@@ -957,7 +957,7 @@ export class MCPCommandController {
957
957
 
958
958
  let connection: MCPServerConnection | undefined;
959
959
  try {
960
- const cwd = process.cwd();
960
+ const cwd = getProjectDir();
961
961
  const userPath = getMCPConfigPath("user", cwd);
962
962
  const projectPath = getMCPConfigPath("project", cwd);
963
963
 
@@ -989,7 +989,7 @@ export class MCPCommandController {
989
989
  if (this.ctx.mcpManager) {
990
990
  resolvedConfig = await this.ctx.mcpManager.prepareConfig(config);
991
991
  } else {
992
- const tempManager = new MCPManager(process.cwd());
992
+ const tempManager = new MCPManager(getProjectDir());
993
993
  tempManager.setAuthStorage(this.ctx.session.modelRegistry.authStorage);
994
994
  resolvedConfig = await tempManager.prepareConfig(config);
995
995
  }
@@ -1,8 +1,8 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
- import type { OAuthProvider } from "@oh-my-pi/pi-ai";
2
+ import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
5
- import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
5
+ import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
6
6
  import { MODEL_ROLES } from "../../config/model-registry";
7
7
  import { settings } from "../../config/settings";
8
8
  import { DebugSelectorComponent } from "../../debug";
@@ -32,6 +32,16 @@ import { setPreferredImageProvider, setPreferredSearchProvider } from "../../too
32
32
  export class SelectorController {
33
33
  constructor(private ctx: InteractiveModeContext) {}
34
34
 
35
+ async #refreshOAuthProviderAuthState(): Promise<void> {
36
+ const oauthProviders = getOAuthProviders();
37
+ await Promise.all(
38
+ oauthProviders.map(provider =>
39
+ this.ctx.session.modelRegistry
40
+ .getApiKeyForProvider(provider.id, this.ctx.session.sessionId)
41
+ .catch(() => undefined),
42
+ ),
43
+ );
44
+ }
35
45
  /**
36
46
  * Shows a selector component in place of the editor.
37
47
  * @param create Factory that receives a `done` callback and returns the component and focus target
@@ -57,7 +67,7 @@ export class SelectorController {
57
67
  availableThinkingLevels: this.ctx.session.getAvailableThinkingLevels(),
58
68
  thinkingLevel: this.ctx.session.thinkingLevel,
59
69
  availableThemes,
60
- cwd: process.cwd(),
70
+ cwd: getProjectDir(),
61
71
  },
62
72
  {
63
73
  onChange: (id, value) => this.handleSettingChange(id, value),
@@ -136,7 +146,7 @@ export class SelectorController {
136
146
  * Replaces /status with a unified view of all providers and extensions.
137
147
  */
138
148
  async showExtensionsDashboard(): Promise<void> {
139
- const dashboard = await ExtensionDashboard.create(process.cwd(), this.ctx.settings, this.ctx.ui.terminal.rows);
149
+ const dashboard = await ExtensionDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows);
140
150
  this.showSelector(done => {
141
151
  dashboard.onClose = () => {
142
152
  done();
@@ -536,8 +546,11 @@ export class SelectorController {
536
546
 
537
547
  async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
538
548
  if (mode === "logout") {
539
- const providers = this.ctx.session.modelRegistry.authStorage.list();
540
- const loggedInProviders = providers.filter(p => this.ctx.session.modelRegistry.authStorage.hasOAuth(p));
549
+ await this.#refreshOAuthProviderAuthState();
550
+ const oauthProviders = getOAuthProviders();
551
+ const loggedInProviders = oauthProviders.filter(provider =>
552
+ this.ctx.session.modelRegistry.authStorage.hasAuth(provider.id),
553
+ );
541
554
  if (loggedInProviders.length === 0) {
542
555
  this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
543
556
  return;
@@ -545,15 +558,15 @@ export class SelectorController {
545
558
  }
546
559
 
547
560
  this.showSelector(done => {
548
- const selector = new OAuthSelectorComponent(
561
+ let selector: OAuthSelectorComponent;
562
+ selector = new OAuthSelectorComponent(
549
563
  mode,
550
564
  this.ctx.session.modelRegistry.authStorage,
551
565
  async (providerId: string) => {
566
+ selector.stopValidation();
552
567
  done();
553
-
554
568
  if (mode === "login") {
555
569
  this.ctx.showStatus(`Logging in to ${providerId}…`);
556
-
557
570
  try {
558
571
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
559
572
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -567,7 +580,6 @@ export class SelectorController {
567
580
  this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
568
581
  }
569
582
  this.ctx.ui.requestRender();
570
-
571
583
  this.ctx.openInBrowser(info.url);
572
584
  },
573
585
  onPrompt: async (prompt: { message: string; placeholder?: string }) => {
@@ -577,7 +589,6 @@ export class SelectorController {
577
589
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
578
590
  }
579
591
  this.ctx.ui.requestRender();
580
-
581
592
  return new Promise<string>(resolve => {
582
593
  const codeInput = new Input();
583
594
  codeInput.onSubmit = () => {
@@ -638,9 +649,22 @@ export class SelectorController {
638
649
  }
639
650
  },
640
651
  () => {
652
+ selector.stopValidation();
641
653
  done();
642
654
  this.ctx.ui.requestRender();
643
655
  },
656
+ {
657
+ validateAuth: async (providerId: string) => {
658
+ const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
659
+ providerId,
660
+ this.ctx.session.sessionId,
661
+ );
662
+ return !!apiKey;
663
+ },
664
+ requestRender: () => {
665
+ this.ctx.ui.requestRender();
666
+ },
667
+ },
644
668
  );
645
669
  return { component: selector, focus: selector };
646
670
  });
@@ -16,7 +16,7 @@ import {
16
16
  TUI,
17
17
  } from "@oh-my-pi/pi-tui";
18
18
  import { $env, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
19
- import { APP_NAME } from "@oh-my-pi/pi-utils/dirs";
19
+ import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
20
20
  import chalk from "chalk";
21
21
  import { KeybindingsManager } from "../config/keybindings";
22
22
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -249,7 +249,7 @@ export class InteractiveMode implements InteractiveModeContext {
249
249
  this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
250
250
  debugStartup("InteractiveMode.init:cleanupRegistered");
251
251
 
252
- await this.refreshSlashCommandState(process.cwd());
252
+ await this.refreshSlashCommandState(getProjectDir());
253
253
  debugStartup("InteractiveMode.init:slashCommands");
254
254
 
255
255
  // Get current model info for welcome screen