@oh-my-pi/pi-coding-agent 4.1.0 → 4.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 (90) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-session.ts +3 -3
  7. package/src/core/agent-storage.ts +450 -0
  8. package/src/core/auth-storage.ts +102 -183
  9. package/src/core/compaction/branch-summarization.ts +5 -4
  10. package/src/core/compaction/compaction.ts +7 -6
  11. package/src/core/compaction/utils.ts +6 -11
  12. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  13. package/src/core/custom-share.ts +66 -0
  14. package/src/core/export-html/index.ts +1 -33
  15. package/src/core/history-storage.ts +15 -7
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +130 -290
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/complete.ts +5 -2
  27. package/src/core/tools/edit.ts +2 -1
  28. package/src/core/tools/find.ts +2 -1
  29. package/src/core/tools/gemini-image.ts +2 -1
  30. package/src/core/tools/git.ts +2 -2
  31. package/src/core/tools/grep.ts +2 -1
  32. package/src/core/tools/index.test.ts +0 -28
  33. package/src/core/tools/index.ts +0 -6
  34. package/src/core/tools/lsp/index.ts +2 -1
  35. package/src/core/tools/output.ts +2 -1
  36. package/src/core/tools/read.ts +4 -1
  37. package/src/core/tools/ssh.ts +4 -2
  38. package/src/core/tools/task/agents.ts +56 -30
  39. package/src/core/tools/task/commands.ts +5 -8
  40. package/src/core/tools/task/index.ts +7 -15
  41. package/src/core/tools/web-fetch.ts +2 -1
  42. package/src/core/tools/web-search/auth.ts +106 -16
  43. package/src/core/tools/web-search/index.ts +3 -2
  44. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  45. package/src/core/tools/write.ts +2 -1
  46. package/src/core/voice.ts +3 -1
  47. package/src/discovery/builtin.ts +9 -54
  48. package/src/discovery/claude.ts +16 -69
  49. package/src/discovery/codex.ts +11 -36
  50. package/src/discovery/helpers.ts +52 -1
  51. package/src/main.ts +1 -1
  52. package/src/migrations.ts +20 -20
  53. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  54. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  55. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  56. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  57. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  58. package/src/modes/interactive/interactive-mode.ts +363 -3139
  59. package/src/modes/interactive/theme/theme.ts +5 -5
  60. package/src/modes/interactive/types.ts +189 -0
  61. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  62. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  63. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  64. package/src/prompts/agents/frontmatter.md +7 -0
  65. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  66. package/src/prompts/agents/planner.md +112 -0
  67. package/src/prompts/agents/task.md +15 -0
  68. package/src/prompts/review-request.md +44 -8
  69. package/src/prompts/system/custom-system-prompt.md +80 -0
  70. package/src/prompts/system/file-operations.md +12 -0
  71. package/src/prompts/system/system-prompt.md +237 -0
  72. package/src/prompts/system/title-system.md +2 -0
  73. package/src/prompts/tools/bash.md +1 -1
  74. package/src/prompts/tools/read.md +1 -1
  75. package/src/prompts/tools/task.md +34 -22
  76. package/src/core/tools/rulebook.ts +0 -132
  77. package/src/prompts/architect-plan.md +0 -10
  78. package/src/prompts/implement-with-critic.md +0 -11
  79. package/src/prompts/implement.md +0 -11
  80. package/src/prompts/system-prompt.md +0 -43
  81. package/src/prompts/task.md +0 -14
  82. package/src/prompts/title-system.md +0 -8
  83. /package/src/prompts/{init.md → agents/init.md} +0 -0
  84. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  85. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  86. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  87. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  88. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  89. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  90. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
@@ -0,0 +1,585 @@
1
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
+ import type { OAuthProvider } from "@oh-my-pi/pi-ai";
3
+ import type { Component } from "@oh-my-pi/pi-tui";
4
+ import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
5
+ import { getAgentDbPath } from "../../../config";
6
+ import { SessionManager } from "../../../core/session-manager";
7
+ import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../../core/tools/index";
8
+ import { disableProvider, enableProvider } from "../../../discovery";
9
+ import { AssistantMessageComponent } from "../components/assistant-message";
10
+ import { ExtensionDashboard } from "../components/extensions";
11
+ import { HistorySearchComponent } from "../components/history-search";
12
+ import { ModelSelectorComponent } from "../components/model-selector";
13
+ import { OAuthSelectorComponent } from "../components/oauth-selector";
14
+ import { SessionSelectorComponent } from "../components/session-selector";
15
+ import { SettingsSelectorComponent } from "../components/settings-selector";
16
+ import { ToolExecutionComponent } from "../components/tool-execution";
17
+ import { TreeSelectorComponent } from "../components/tree-selector";
18
+ import { UserMessageSelectorComponent } from "../components/user-message-selector";
19
+ import { getAvailableThemes, getSymbolTheme, setSymbolPreset, setTheme, theme } from "../theme/theme";
20
+ import type { InteractiveModeContext } from "../types";
21
+
22
+ export class SelectorController {
23
+ constructor(private ctx: InteractiveModeContext) {}
24
+
25
+ /**
26
+ * Shows a selector component in place of the editor.
27
+ * @param create Factory that receives a `done` callback and returns the component and focus target
28
+ */
29
+ showSelector(create: (done: () => void) => { component: Component; focus: Component }): void {
30
+ const done = () => {
31
+ this.ctx.editorContainer.clear();
32
+ this.ctx.editorContainer.addChild(this.ctx.editor);
33
+ this.ctx.ui.setFocus(this.ctx.editor);
34
+ };
35
+ const { component, focus } = create(done);
36
+ this.ctx.editorContainer.clear();
37
+ this.ctx.editorContainer.addChild(component);
38
+ this.ctx.ui.setFocus(focus);
39
+ this.ctx.ui.requestRender();
40
+ }
41
+
42
+ showSettingsSelector(): void {
43
+ this.showSelector((done) => {
44
+ const selector = new SettingsSelectorComponent(
45
+ this.ctx.settingsManager,
46
+ {
47
+ availableThinkingLevels: this.ctx.session.getAvailableThinkingLevels(),
48
+ thinkingLevel: this.ctx.session.thinkingLevel,
49
+ availableThemes: getAvailableThemes(),
50
+ cwd: process.cwd(),
51
+ },
52
+ {
53
+ onChange: (id, value) => this.handleSettingChange(id, value),
54
+ onThemePreview: (themeName) => {
55
+ const result = setTheme(themeName, true);
56
+ if (result.success) {
57
+ this.ctx.ui.invalidate();
58
+ this.ctx.ui.requestRender();
59
+ }
60
+ },
61
+ onStatusLinePreview: (settings) => {
62
+ // Update status line with preview settings
63
+ const currentSettings = this.ctx.settingsManager.getStatusLineSettings();
64
+ this.ctx.statusLine.updateSettings({ ...currentSettings, ...settings });
65
+ this.ctx.updateEditorTopBorder();
66
+ this.ctx.ui.requestRender();
67
+ },
68
+ getStatusLinePreview: () => {
69
+ // Return the rendered status line for inline preview
70
+ const width = this.ctx.ui.getWidth();
71
+ return this.ctx.statusLine.getTopBorder(width).content;
72
+ },
73
+ onPluginsChanged: () => {
74
+ this.ctx.ui.requestRender();
75
+ },
76
+ onCancel: () => {
77
+ done();
78
+ // Restore status line to saved settings
79
+ this.ctx.statusLine.updateSettings(this.ctx.settingsManager.getStatusLineSettings());
80
+ this.ctx.updateEditorTopBorder();
81
+ this.ctx.ui.requestRender();
82
+ },
83
+ },
84
+ );
85
+ return { component: selector, focus: selector };
86
+ });
87
+ }
88
+
89
+ showHistorySearch(): void {
90
+ const historyStorage = this.ctx.historyStorage;
91
+ if (!historyStorage) return;
92
+
93
+ this.showSelector((done) => {
94
+ const component = new HistorySearchComponent(
95
+ historyStorage,
96
+ (prompt) => {
97
+ done();
98
+ this.ctx.editor.setText(prompt);
99
+ this.ctx.ui.requestRender();
100
+ },
101
+ () => {
102
+ done();
103
+ this.ctx.ui.requestRender();
104
+ },
105
+ );
106
+ return { component, focus: component };
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Show the Extension Control Center dashboard.
112
+ * Replaces /status with a unified view of all providers and extensions.
113
+ */
114
+ showExtensionsDashboard(): void {
115
+ this.showSelector((done) => {
116
+ const dashboard = new ExtensionDashboard(process.cwd(), this.ctx.settingsManager, this.ctx.ui.terminal.rows);
117
+ dashboard.onClose = () => {
118
+ done();
119
+ this.ctx.ui.requestRender();
120
+ };
121
+ return { component: dashboard, focus: dashboard };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Handle setting changes from the settings selector.
127
+ * Most settings are saved directly via SettingsManager in the definitions.
128
+ * This handles side effects and session-specific settings.
129
+ */
130
+ handleSettingChange(id: string, value: string | boolean): void {
131
+ // Discovery provider toggles
132
+ if (id.startsWith("discovery.")) {
133
+ const providerId = id.replace("discovery.", "");
134
+ if (value) {
135
+ enableProvider(providerId);
136
+ } else {
137
+ disableProvider(providerId);
138
+ }
139
+ return;
140
+ }
141
+
142
+ switch (id) {
143
+ // Session-managed settings (not in SettingsManager)
144
+ case "autoCompact":
145
+ this.ctx.session.setAutoCompactionEnabled(value as boolean);
146
+ this.ctx.statusLine.setAutoCompactEnabled(value as boolean);
147
+ break;
148
+ case "steeringMode":
149
+ this.ctx.session.setSteeringMode(value as "all" | "one-at-a-time");
150
+ break;
151
+ case "followUpMode":
152
+ this.ctx.session.setFollowUpMode(value as "all" | "one-at-a-time");
153
+ break;
154
+ case "interruptMode":
155
+ this.ctx.session.setInterruptMode(value as "immediate" | "wait");
156
+ break;
157
+ case "thinkingLevel":
158
+ this.ctx.session.setThinkingLevel(value as ThinkingLevel);
159
+ this.ctx.statusLine.invalidate();
160
+ this.ctx.updateEditorBorderColor();
161
+ break;
162
+
163
+ // Settings with UI side effects
164
+ case "showImages":
165
+ for (const child of this.ctx.chatContainer.children) {
166
+ if (child instanceof ToolExecutionComponent) {
167
+ child.setShowImages(value as boolean);
168
+ }
169
+ }
170
+ break;
171
+ case "hideThinking":
172
+ this.ctx.hideThinkingBlock = value as boolean;
173
+ for (const child of this.ctx.chatContainer.children) {
174
+ if (child instanceof AssistantMessageComponent) {
175
+ child.setHideThinkingBlock(value as boolean);
176
+ }
177
+ }
178
+ this.ctx.chatContainer.clear();
179
+ this.ctx.rebuildChatFromMessages();
180
+ break;
181
+ case "theme": {
182
+ const result = setTheme(value as string, true);
183
+ this.ctx.statusLine.invalidate();
184
+ this.ctx.updateEditorTopBorder();
185
+ this.ctx.ui.invalidate();
186
+ if (!result.success) {
187
+ this.ctx.showError(`Failed to load theme "${value}": ${result.error}\nFell back to dark theme.`);
188
+ }
189
+ break;
190
+ }
191
+ case "symbolPreset": {
192
+ setSymbolPreset(value as "unicode" | "nerd" | "ascii");
193
+ this.ctx.statusLine.invalidate();
194
+ this.ctx.updateEditorTopBorder();
195
+ this.ctx.ui.invalidate();
196
+ break;
197
+ }
198
+ case "voiceEnabled": {
199
+ if (!value) {
200
+ this.ctx.voiceAutoModeEnabled = false;
201
+ this.ctx.stopVoiceProgressTimer();
202
+ void this.ctx.voiceSupervisor.stop();
203
+ this.ctx.setVoiceStatus(undefined);
204
+ }
205
+ break;
206
+ }
207
+ case "statusLinePreset":
208
+ case "statusLineSeparator":
209
+ case "statusLineShowHooks":
210
+ case "statusLineSegments":
211
+ case "statusLineModelThinking":
212
+ case "statusLinePathAbbreviate":
213
+ case "statusLinePathMaxLength":
214
+ case "statusLinePathStripWorkPrefix":
215
+ case "statusLineGitShowBranch":
216
+ case "statusLineGitShowStaged":
217
+ case "statusLineGitShowUnstaged":
218
+ case "statusLineGitShowUntracked":
219
+ case "statusLineTimeFormat":
220
+ case "statusLineTimeShowSeconds": {
221
+ this.ctx.statusLine.updateSettings(this.ctx.settingsManager.getStatusLineSettings());
222
+ this.ctx.updateEditorTopBorder();
223
+ this.ctx.ui.requestRender();
224
+ break;
225
+ }
226
+
227
+ // Provider settings - update runtime preferences
228
+ case "webSearchProvider":
229
+ setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic");
230
+ break;
231
+ case "imageProvider":
232
+ setPreferredImageProvider(value as "auto" | "gemini" | "openrouter");
233
+ break;
234
+
235
+ // All other settings are handled by the definitions (get/set on SettingsManager)
236
+ // No additional side effects needed
237
+ }
238
+ }
239
+
240
+ showModelSelector(options?: { temporaryOnly?: boolean }): void {
241
+ this.showSelector((done) => {
242
+ const selector = new ModelSelectorComponent(
243
+ this.ctx.ui,
244
+ this.ctx.session.model,
245
+ this.ctx.settingsManager,
246
+ this.ctx.session.modelRegistry,
247
+ this.ctx.session.scopedModels,
248
+ async (model, role) => {
249
+ try {
250
+ if (role === "temporary") {
251
+ // Temporary: update agent state but don't persist to settings
252
+ await this.ctx.session.setModelTemporary(model);
253
+ this.ctx.statusLine.invalidate();
254
+ this.ctx.updateEditorBorderColor();
255
+ this.ctx.showStatus(`Temporary model: ${model.id}`);
256
+ done();
257
+ this.ctx.ui.requestRender();
258
+ } else if (role === "default") {
259
+ // Default: update agent state and persist
260
+ await this.ctx.session.setModel(model, role);
261
+ this.ctx.statusLine.invalidate();
262
+ this.ctx.updateEditorBorderColor();
263
+ this.ctx.showStatus(`Default model: ${model.id}`);
264
+ // Don't call done() - selector stays open for role assignment
265
+ } else {
266
+ // Other roles (smol, slow): just update settings, not current model
267
+ const roleLabel = role === "smol" ? "Smol" : role;
268
+ this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
269
+ // Don't call done() - selector stays open
270
+ }
271
+ } catch (error) {
272
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
273
+ }
274
+ },
275
+ () => {
276
+ done();
277
+ this.ctx.ui.requestRender();
278
+ },
279
+ options,
280
+ );
281
+ return { component: selector, focus: selector };
282
+ });
283
+ }
284
+
285
+ showUserMessageSelector(): void {
286
+ const userMessages = this.ctx.session.getUserMessagesForBranching();
287
+
288
+ if (userMessages.length === 0) {
289
+ this.ctx.showStatus("No messages to branch from");
290
+ return;
291
+ }
292
+
293
+ this.showSelector((done) => {
294
+ const selector = new UserMessageSelectorComponent(
295
+ userMessages.map((m) => ({ id: m.entryId, text: m.text })),
296
+ async (entryId) => {
297
+ const result = await this.ctx.session.branch(entryId);
298
+ if (result.cancelled) {
299
+ // Hook cancelled the branch
300
+ done();
301
+ this.ctx.ui.requestRender();
302
+ return;
303
+ }
304
+
305
+ this.ctx.chatContainer.clear();
306
+ this.ctx.renderInitialMessages();
307
+ this.ctx.editor.setText(result.selectedText);
308
+ done();
309
+ this.ctx.showStatus("Branched to new session");
310
+ },
311
+ () => {
312
+ done();
313
+ this.ctx.ui.requestRender();
314
+ },
315
+ );
316
+ return { component: selector, focus: selector.getMessageList() };
317
+ });
318
+ }
319
+
320
+ showTreeSelector(): void {
321
+ const tree = this.ctx.sessionManager.getTree();
322
+ const realLeafId = this.ctx.sessionManager.getLeafId();
323
+
324
+ // Find the visible leaf for display (skip metadata entries like labels)
325
+ let visibleLeafId = realLeafId;
326
+ while (visibleLeafId) {
327
+ const entry = this.ctx.sessionManager.getEntry(visibleLeafId);
328
+ if (!entry) break;
329
+ if (entry.type !== "label" && entry.type !== "custom") break;
330
+ visibleLeafId = entry.parentId ?? null;
331
+ }
332
+
333
+ if (tree.length === 0) {
334
+ this.ctx.showStatus("No entries in session");
335
+ return;
336
+ }
337
+
338
+ this.showSelector((done) => {
339
+ const selector = new TreeSelectorComponent(
340
+ tree,
341
+ visibleLeafId,
342
+ this.ctx.ui.terminal.rows,
343
+ async (entryId) => {
344
+ // Selecting the visible leaf is a no-op (already there)
345
+ if (entryId === visibleLeafId) {
346
+ done();
347
+ this.ctx.showStatus("Already at this point");
348
+ return;
349
+ }
350
+
351
+ // Ask about summarization (or skip if disabled in settings)
352
+ done(); // Close selector first
353
+
354
+ const branchSummariesEnabled = this.ctx.settingsManager.getBranchSummaryEnabled();
355
+ const wantsSummary = branchSummariesEnabled
356
+ ? await this.ctx.showHookConfirm(
357
+ "Summarize branch?",
358
+ "Create a summary of the branch you're leaving?",
359
+ )
360
+ : false;
361
+
362
+ // Set up escape handler and loader if summarizing
363
+ let summaryLoader: Loader | undefined;
364
+ const originalOnEscape = this.ctx.editor.onEscape;
365
+
366
+ if (wantsSummary) {
367
+ this.ctx.editor.onEscape = () => {
368
+ this.ctx.session.abortBranchSummary();
369
+ };
370
+ this.ctx.chatContainer.addChild(new Spacer(1));
371
+ summaryLoader = new Loader(
372
+ this.ctx.ui,
373
+ (spinner) => theme.fg("accent", spinner),
374
+ (text) => theme.fg("muted", text),
375
+ "Summarizing branch... (esc to cancel)",
376
+ getSymbolTheme().spinnerFrames,
377
+ );
378
+ this.ctx.statusContainer.addChild(summaryLoader);
379
+ this.ctx.ui.requestRender();
380
+ }
381
+
382
+ try {
383
+ const result = await this.ctx.session.navigateTree(entryId, { summarize: wantsSummary });
384
+
385
+ if (result.aborted) {
386
+ // Summarization aborted - re-show tree selector
387
+ this.ctx.showStatus("Branch summarization cancelled");
388
+ this.showTreeSelector();
389
+ return;
390
+ }
391
+ if (result.cancelled) {
392
+ this.ctx.showStatus("Navigation cancelled");
393
+ return;
394
+ }
395
+
396
+ // Update UI
397
+ this.ctx.chatContainer.clear();
398
+ this.ctx.renderInitialMessages();
399
+ if (result.editorText) {
400
+ this.ctx.editor.setText(result.editorText);
401
+ }
402
+ this.ctx.showStatus("Navigated to selected point");
403
+ } catch (error) {
404
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
405
+ } finally {
406
+ if (summaryLoader) {
407
+ summaryLoader.stop();
408
+ this.ctx.statusContainer.clear();
409
+ }
410
+ this.ctx.editor.onEscape = originalOnEscape;
411
+ }
412
+ },
413
+ () => {
414
+ done();
415
+ this.ctx.ui.requestRender();
416
+ },
417
+ (entryId, label) => {
418
+ this.ctx.sessionManager.appendLabelChange(entryId, label);
419
+ this.ctx.ui.requestRender();
420
+ },
421
+ );
422
+ return { component: selector, focus: selector };
423
+ });
424
+ }
425
+
426
+ showSessionSelector(): void {
427
+ this.showSelector((done) => {
428
+ const sessions = SessionManager.list(
429
+ this.ctx.sessionManager.getCwd(),
430
+ this.ctx.sessionManager.getSessionDir(),
431
+ );
432
+ const selector = new SessionSelectorComponent(
433
+ sessions,
434
+ async (sessionPath) => {
435
+ done();
436
+ await this.handleResumeSession(sessionPath);
437
+ },
438
+ () => {
439
+ done();
440
+ this.ctx.ui.requestRender();
441
+ },
442
+ () => {
443
+ void this.ctx.shutdown();
444
+ },
445
+ );
446
+ return { component: selector, focus: selector.getSessionList() };
447
+ });
448
+ }
449
+
450
+ async handleResumeSession(sessionPath: string): Promise<void> {
451
+ // Stop loading animation
452
+ if (this.ctx.loadingAnimation) {
453
+ this.ctx.loadingAnimation.stop();
454
+ this.ctx.loadingAnimation = undefined;
455
+ }
456
+ this.ctx.statusContainer.clear();
457
+
458
+ // Clear UI state
459
+ this.ctx.pendingMessagesContainer.clear();
460
+ this.ctx.compactionQueuedMessages = [];
461
+ this.ctx.streamingComponent = undefined;
462
+ this.ctx.streamingMessage = undefined;
463
+ this.ctx.pendingTools.clear();
464
+
465
+ // Switch session via AgentSession (emits hook and tool session events)
466
+ await this.ctx.session.switchSession(sessionPath);
467
+
468
+ // Clear and re-render the chat
469
+ this.ctx.chatContainer.clear();
470
+ this.ctx.renderInitialMessages();
471
+ this.ctx.showStatus("Resumed session");
472
+ }
473
+
474
+ async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
475
+ if (mode === "logout") {
476
+ const providers = this.ctx.session.modelRegistry.authStorage.list();
477
+ const loggedInProviders = providers.filter((p) => this.ctx.session.modelRegistry.authStorage.hasOAuth(p));
478
+ if (loggedInProviders.length === 0) {
479
+ this.ctx.showStatus("No OAuth providers logged in. Use /login first.");
480
+ return;
481
+ }
482
+ }
483
+
484
+ this.showSelector((done) => {
485
+ const selector = new OAuthSelectorComponent(
486
+ mode,
487
+ this.ctx.session.modelRegistry.authStorage,
488
+ async (providerId: string) => {
489
+ done();
490
+
491
+ if (mode === "login") {
492
+ this.ctx.showStatus(`Logging in to ${providerId}...`);
493
+
494
+ try {
495
+ await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
496
+ onAuth: (info: { url: string; instructions?: string }) => {
497
+ this.ctx.chatContainer.addChild(new Spacer(1));
498
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
499
+ // Use OSC 8 hyperlink escape sequence for clickable link
500
+ const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
501
+ this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
502
+ if (info.instructions) {
503
+ this.ctx.chatContainer.addChild(new Spacer(1));
504
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
505
+ }
506
+ this.ctx.ui.requestRender();
507
+
508
+ this.ctx.openInBrowser(info.url);
509
+ },
510
+ onPrompt: async (prompt: { message: string; placeholder?: string }) => {
511
+ this.ctx.chatContainer.addChild(new Spacer(1));
512
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
513
+ if (prompt.placeholder) {
514
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
515
+ }
516
+ this.ctx.ui.requestRender();
517
+
518
+ return new Promise<string>((resolve) => {
519
+ const codeInput = new Input();
520
+ codeInput.onSubmit = () => {
521
+ const code = codeInput.getValue();
522
+ this.ctx.editorContainer.clear();
523
+ this.ctx.editorContainer.addChild(this.ctx.editor);
524
+ this.ctx.ui.setFocus(this.ctx.editor);
525
+ resolve(code);
526
+ };
527
+ this.ctx.editorContainer.clear();
528
+ this.ctx.editorContainer.addChild(codeInput);
529
+ this.ctx.ui.setFocus(codeInput);
530
+ this.ctx.ui.requestRender();
531
+ });
532
+ },
533
+ onProgress: (message: string) => {
534
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
535
+ this.ctx.ui.requestRender();
536
+ },
537
+ });
538
+ // Refresh models to pick up new baseUrl (e.g., github-copilot)
539
+ await this.ctx.session.modelRegistry.refresh();
540
+ this.ctx.chatContainer.addChild(new Spacer(1));
541
+ this.ctx.chatContainer.addChild(
542
+ new Text(
543
+ theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`),
544
+ 1,
545
+ 0,
546
+ ),
547
+ );
548
+ this.ctx.chatContainer.addChild(
549
+ new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0),
550
+ );
551
+ this.ctx.ui.requestRender();
552
+ } catch (error: unknown) {
553
+ this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
554
+ }
555
+ } else {
556
+ try {
557
+ await this.ctx.session.modelRegistry.authStorage.logout(providerId);
558
+ // Refresh models to reset baseUrl
559
+ await this.ctx.session.modelRegistry.refresh();
560
+ this.ctx.chatContainer.addChild(new Spacer(1));
561
+ this.ctx.chatContainer.addChild(
562
+ new Text(
563
+ theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`),
564
+ 1,
565
+ 0,
566
+ ),
567
+ );
568
+ this.ctx.chatContainer.addChild(
569
+ new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
570
+ );
571
+ this.ctx.ui.requestRender();
572
+ } catch (error: unknown) {
573
+ this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
574
+ }
575
+ }
576
+ },
577
+ () => {
578
+ done();
579
+ this.ctx.ui.requestRender();
580
+ },
581
+ );
582
+ return { component: selector, focus: selector };
583
+ });
584
+ }
585
+ }