@oh-my-pi/pi-coding-agent 13.7.1 → 13.7.4

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.7.4] - 2026-03-04
6
+ ### Added
7
+ - Added `fetch.useKagiSummarizer` setting to toggle Kagi Universal Summarizer usage in the fetch tool.
8
+
9
+ ### Fixed
10
+
11
+ - Fixed incorrect message history reference in session title generation that could cause missing or stale titles on first message
12
+ - Added startup check requiring Bun 1.3.7+ for JSONL session parsing (`Bun.JSONL.parseChunk`) and clear upgrade guidance so `/resume` and `--resume` do not silently report missing sessions on older Bun runtimes
13
+
14
+ ## [13.7.3] - 2026-03-04
15
+
16
+ ### Added
17
+
18
+ - Added Kagi Universal Summarizer integration for URL summarization, now prioritized before Jina and other methods
19
+ - Added Kagi Universal Summarizer support for YouTube video summaries when credentials are available
20
+ - Exported `searchWithKagi` and `summarizeUrlWithKagi` functions from new `web/kagi` module for direct API access
21
+ - Added `KagiApiError` exception class for Kagi API-specific error handling
22
+
23
+ ### Changed
24
+
25
+ - Updated hashline prompt documentation with clearer operation syntax and improved examples showing full edit structure with path and edits array
26
+ - Refactored `hlineref` Handlebars helper to return JSON-quoted strings for safer embedding in JSON blocks within prompts
27
+ - Improved `hashlineParseText` to correctly preserve blank lines and trailing empty strings in array input while stripping trailing newlines from string input
28
+ - Optimized duplicate line detection in range replacements to use trimmed comparison, reducing false positives from whitespace differences
29
+ - Refactored Kagi search provider to use shared Kagi API utilities from `web/kagi` module
30
+ - Changed HTML-to-text rendering priority order to try Kagi first, then Jina, Trafilatura, and Lynx
31
+
32
+ ### Fixed
33
+
34
+ - Fixed `isEscapedTabAutocorrectEnabled` environment variable parsing to use switch statement for clearer logic and consistent default behavior
35
+
36
+ ## [13.7.2] - 2026-03-04
37
+ ### Added
38
+
39
+ - Added support for direct OAuth provider login via `/login <provider>` command (e.g., `/login kagi`)
40
+ - Added optional `providerId` parameter to `showOAuthSelector()` to enable direct provider selection without UI selector
41
+
42
+ ### Changed
43
+
44
+ - Simplified web search result formatting to omit empty sections and metadata when not present
45
+
5
46
  ## [13.7.0] - 2026-03-03
6
47
 
7
48
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.7.1",
4
+ "version": "13.7.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.7.1",
45
- "@oh-my-pi/pi-agent-core": "13.7.1",
46
- "@oh-my-pi/pi-ai": "13.7.1",
47
- "@oh-my-pi/pi-natives": "13.7.1",
48
- "@oh-my-pi/pi-tui": "13.7.1",
49
- "@oh-my-pi/pi-utils": "13.7.1",
44
+ "@oh-my-pi/omp-stats": "13.7.4",
45
+ "@oh-my-pi/pi-agent-core": "13.7.4",
46
+ "@oh-my-pi/pi-ai": "13.7.4",
47
+ "@oh-my-pi/pi-natives": "13.7.4",
48
+ "@oh-my-pi/pi-tui": "13.7.4",
49
+ "@oh-my-pi/pi-utils": "13.7.4",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
package/src/cli.ts CHANGED
@@ -1,11 +1,39 @@
1
1
  #!/usr/bin/env bun
2
- import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils";
2
+ import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils";
3
3
  /**
4
4
  * CLI entry point — registers all commands explicitly and delegates to the
5
5
  * lightweight CLI runner from pi-utils.
6
6
  */
7
7
  import { type CommandEntry, run } from "@oh-my-pi/pi-utils/cli";
8
8
 
9
+ function parseSemver(version: string): [number, number, number] {
10
+ function toint(value: string): number {
11
+ const int = Number.parseInt(value, 10);
12
+ if (Number.isNaN(int) || !Number.isFinite(int)) return 0;
13
+ return int;
14
+ }
15
+ const [majorRaw, minorRaw, patchRaw] = version.split(".").map(toint);
16
+ return [majorRaw, minorRaw, patchRaw];
17
+ }
18
+
19
+ function isAtLeastBunVersion(minimum: string): boolean {
20
+ const ver = parseSemver(Bun.version);
21
+ const min = parseSemver(minimum);
22
+ for (let i = 0; i < 3; i++) {
23
+ if (ver[i] !== min[i]) {
24
+ return ver[i] > min[i];
25
+ }
26
+ }
27
+ return true;
28
+ }
29
+
30
+ if (typeof Bun.JSONL?.parseChunk !== "function" || !isAtLeastBunVersion(MIN_BUN_VERSION)) {
31
+ process.stderr.write(
32
+ `error: Bun runtime must be >= ${MIN_BUN_VERSION} (found v${Bun.version}). Please update Bun: bun upgrade\n`,
33
+ );
34
+ process.exit(1);
35
+ }
36
+
9
37
  // Detect known Bun errata that cause TUI crashes (e.g. Bun.stringWidth mishandling OSC sequences).
10
38
  if (Bun.stringWidth("\x1b[0m\x1b]8;;\x07") !== 0) {
11
39
  process.stderr.write(`error: Bun runtime errata detected (v${Bun.version}). Please update Bun: bun upgrade\n`);
@@ -249,10 +249,6 @@ export function sectionSeparator(name: string): string {
249
249
 
250
250
  handlebars.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectionSeparator(String(name)));
251
251
 
252
- /**
253
- * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
254
- * Returns `"lineNum#hash"` using the actual hash algorithm.
255
- */
256
252
  function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
257
253
  const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
258
254
  const raw = typeof content === "string" ? content : String(content ?? "");
@@ -261,16 +257,11 @@ function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; t
261
257
  return { num, text, ref };
262
258
  }
263
259
 
264
- handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
265
- const { ref } = formatHashlineRef(lineNum, content);
266
- return ref;
267
- });
268
-
269
260
  /**
270
- * {{hlinejsonref lineNum "content"}} — same as hlineref but returns a JSON-quoted string.
271
- * Useful for embedding hashline refs inside JSON blocks in prompts.
261
+ * {{hlineref lineNum "content"}} — compute a real hashline ref for prompt examples.
262
+ * Returns `"lineNum#hash"` using the actual hash algorithm.
272
263
  */
273
- handlebars.registerHelper("hlinejsonref", (lineNum: unknown, content: unknown): string => {
264
+ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): string => {
274
265
  const { ref } = formatHashlineRef(lineNum, content);
275
266
  return JSON.stringify(ref);
276
267
  });
@@ -490,6 +490,15 @@ export const SETTINGS_SCHEMA = {
490
490
  default: true,
491
491
  ui: { tab: "tools", label: "Enable Fetch", description: "Enable the fetch tool for URL fetching" },
492
492
  },
493
+ "fetch.useKagiSummarizer": {
494
+ type: "boolean",
495
+ default: true,
496
+ ui: {
497
+ tab: "tools",
498
+ label: "Use Kagi in Fetch",
499
+ description: "Use Kagi Universal Summarizer when rendering HTML in fetch",
500
+ },
501
+ },
493
502
  "web_search.enabled": {
494
503
  type: "boolean",
495
504
  default: true,
@@ -1,4 +1,4 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-natives";
1
+ import { sanitizeText, wrapTextWithAnsi } from "@oh-my-pi/pi-natives";
2
2
  import { replaceTabs, truncateToWidth } from "../tools/render-utils";
3
3
 
4
4
  export function formatDebugLogLine(line: string, maxWidth: number): string {
@@ -17,9 +17,7 @@ export function formatDebugLogExpandedLines(line: string, maxWidth: number): str
17
17
  return [""];
18
18
  }
19
19
 
20
- return normalized
21
- .split("\n")
22
- .flatMap(segment => Bun.wrapAnsi(segment, width, { hard: true, trim: false, wordWrap: true }).split("\n"));
20
+ return normalized.split("\n").flatMap(segment => wrapTextWithAnsi(segment, width));
23
21
  }
24
22
 
25
23
  export function parseDebugLogTimestampMs(line: string): number | undefined {
@@ -499,7 +499,7 @@ export class DebugLogViewerComponent implements Component {
499
499
  }
500
500
 
501
501
  if (matchesKey(keyData, "ctrl+c")) {
502
- void this.#copySelected();
502
+ this.#copySelected();
503
503
  return;
504
504
  }
505
505
 
@@ -878,7 +878,7 @@ export class DebugLogViewerComponent implements Component {
878
878
  return `${theme.boxSharp.vertical}${truncated}${padding(remaining)}${theme.boxSharp.vertical}`;
879
879
  }
880
880
 
881
- async #copySelected(): Promise<void> {
881
+ #copySelected() {
882
882
  const selectedPayload = buildLogCopyPayload(this.#model.getSelectedRawLines());
883
883
  const selected = selectedPayload.length === 0 ? [] : selectedPayload.split("\n");
884
884
 
@@ -890,7 +890,7 @@ export class DebugLogViewerComponent implements Component {
890
890
  }
891
891
 
892
892
  try {
893
- await copyToClipboard(selectedPayload);
893
+ copyToClipboard(selectedPayload);
894
894
  const message = `Copied ${selected.length} log ${selected.length === 1 ? "entry" : "entries"}`;
895
895
  this.#statusMessage = message;
896
896
  this.#onStatus?.(message);
@@ -30,7 +30,7 @@ export function createExaTool(
30
30
  parameters,
31
31
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
32
32
  try {
33
- const apiKey = await findApiKey();
33
+ const apiKey = findApiKey();
34
34
  // Exa MCP endpoint is publicly accessible; API key is optional
35
35
  const args = transformParams ? transformParams(params as Record<string, unknown>) : params;
36
36
  const response = await callExaTool(mcpToolName, args, apiKey);
@@ -238,7 +238,7 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
238
238
  _signal?: AbortSignal,
239
239
  ): Promise<CustomToolResult<ExaRenderDetails>> {
240
240
  try {
241
- const apiKey = await findApiKey();
241
+ const apiKey = findApiKey();
242
242
  // Websets tools require an API key; basic Exa MCP tools work without one
243
243
  if (!apiKey && this.config.isWebsetsTool) {
244
244
  return {
@@ -23,7 +23,7 @@ function createWebsetTool(
23
23
  parameters,
24
24
  async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
25
25
  try {
26
- const apiKey = await findApiKey();
26
+ const apiKey = findApiKey();
27
27
  if (!apiKey) {
28
28
  return {
29
29
  content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }],
@@ -230,7 +230,7 @@ async function startGatewayProcess(
230
230
  const settings = await Settings.init();
231
231
  const { shell, env } = settings.getShellConfig();
232
232
  const filteredEnv = filterEnv(env);
233
- const runtime = await resolvePythonRuntime(cwd, filteredEnv);
233
+ const runtime = resolvePythonRuntime(cwd, filteredEnv);
234
234
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
235
235
  logger.warn("Failed to resolve shell snapshot for shared Python gateway", {
236
236
  error: err instanceof Error ? err.message : String(err),
@@ -89,7 +89,7 @@ export class AssistantMessageComponent extends Container {
89
89
  try {
90
90
  for (const content of message.content) {
91
91
  if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
92
- await prerenderMermaid(content.text);
92
+ prerenderMermaid(content.text);
93
93
  }
94
94
  }
95
95
  } catch (error) {
@@ -30,7 +30,7 @@ import {
30
30
  stripInternalArgs,
31
31
  } from "../../tools/json-tree";
32
32
  import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
33
- import { formatExpandHint, truncateToWidth } from "../../tools/render-utils";
33
+ import { formatExpandHint, replaceTabs, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { renderStatusLine } from "../../tui";
36
36
  import { convertToPng } from "../../utils/image-convert";
@@ -431,14 +431,14 @@ export class ToolExecutionComponent extends Container {
431
431
  // Fall back to showing raw output on error
432
432
  const output = this.#getTextOutput();
433
433
  if (output) {
434
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
434
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
435
435
  }
436
436
  }
437
437
  } else if (this.#result) {
438
438
  // Has result but no custom renderResult
439
439
  const output = this.#getTextOutput();
440
440
  if (output) {
441
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
441
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
442
442
  }
443
443
  }
444
444
  } else if (this.#toolName in toolRenderers) {
@@ -488,7 +488,7 @@ export class ToolExecutionComponent extends Container {
488
488
  // Fall back to showing raw output on error
489
489
  const output = this.#getTextOutput();
490
490
  if (output) {
491
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0));
491
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
492
492
  }
493
493
  }
494
494
  }
@@ -679,7 +679,7 @@ export class ToolExecutionComponent extends Container {
679
679
  const displayLines = outputLines.slice(0, maxOutputLines);
680
680
 
681
681
  for (const line of displayLines) {
682
- lines.push(theme.fg("toolOutput", truncateToWidth(line, 80)));
682
+ lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80)));
683
683
  }
684
684
 
685
685
  if (outputLines.length > maxOutputLines) {
@@ -57,14 +57,14 @@ export class CommandController {
57
57
  }
58
58
  }
59
59
 
60
- async handleDumpCommand(): Promise<void> {
60
+ handleDumpCommand() {
61
61
  try {
62
62
  const formatted = this.ctx.session.formatSessionAsText();
63
63
  if (!formatted) {
64
64
  this.ctx.showError("No messages to dump yet.");
65
65
  return;
66
66
  }
67
- await copyToClipboard(formatted);
67
+ copyToClipboard(formatted);
68
68
  this.ctx.showStatus("Session copied to clipboard");
69
69
  } catch (error: unknown) {
70
70
  this.ctx.showError(`Failed to copy session: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -214,7 +214,7 @@ export class CommandController {
214
214
  }
215
215
  }
216
216
 
217
- async handleCopyCommand(): Promise<void> {
217
+ handleCopyCommand() {
218
218
  const text = this.ctx.session.getLastAssistantText();
219
219
  if (!text) {
220
220
  this.ctx.showError("No agent messages to copy yet.");
@@ -222,7 +222,7 @@ export class CommandController {
222
222
  }
223
223
 
224
224
  try {
225
- await copyToClipboard(text);
225
+ copyToClipboard(text);
226
226
  this.ctx.showStatus("Copied last agent message to clipboard");
227
227
  } catch (error) {
228
228
  this.ctx.showError(error instanceof Error ? error.message : String(error));
@@ -300,7 +300,7 @@ export class InputController {
300
300
  this.ctx.flushPendingBashComponents();
301
301
 
302
302
  // Generate session title on first message
303
- const hasUserMessages = this.ctx.agent.state.messages.some((m: AgentMessage) => m.role === "user");
303
+ const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
304
304
  if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
305
305
  const registry = this.ctx.session.modelRegistry;
306
306
  const smolModel = this.ctx.settings.getModelRole("smol");
@@ -393,7 +393,7 @@ export class InputController {
393
393
  if (allQueued.length === 0) {
394
394
  this.ctx.updatePendingMessagesDisplay();
395
395
  if (options?.abort) {
396
- this.ctx.agent.abort();
396
+ this.ctx.session.abort();
397
397
  }
398
398
  return 0;
399
399
  }
@@ -403,7 +403,7 @@ export class InputController {
403
403
  this.ctx.editor.setText(combinedText);
404
404
  this.ctx.updatePendingMessagesDisplay();
405
405
  if (options?.abort) {
406
- this.ctx.agent.abort();
406
+ this.ctx.session.abort();
407
407
  }
408
408
  return allQueued.length;
409
409
  }
@@ -510,15 +510,14 @@ export class InputController {
510
510
  this.ctx.showStatus("Nothing to copy");
511
511
  return;
512
512
  }
513
- copyToClipboard(text)
514
- .then(() => {
515
- const sanitized = sanitizeText(text);
516
- const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
517
- this.ctx.showStatus(`Copied: ${preview}`);
518
- })
519
- .catch(() => {
520
- this.ctx.showWarning("Failed to copy to clipboard");
521
- });
513
+ try {
514
+ copyToClipboard(text);
515
+ const sanitized = sanitizeText(text);
516
+ const preview = sanitized.length > 30 ? `${sanitized.slice(0, 30)}...` : sanitized;
517
+ this.ctx.showStatus(`Copied: ${preview}`);
518
+ } catch {
519
+ this.ctx.showWarning("Failed to copy to clipboard");
520
+ }
522
521
  }
523
522
 
524
523
  cycleThinkingLevel(): void {
@@ -617,7 +617,99 @@ export class SelectorController {
617
617
  this.ctx.showStatus("Resumed session");
618
618
  }
619
619
 
620
- async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
620
+ async #handleOAuthLogin(providerId: string): Promise<void> {
621
+ this.ctx.showStatus(`Logging in to ${providerId}…`);
622
+ const manualInput = this.ctx.oauthManualInput;
623
+ const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
624
+ try {
625
+ await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
626
+ onAuth: (info: { url: string; instructions?: string }) => {
627
+ this.ctx.chatContainer.addChild(new Spacer(1));
628
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
629
+ const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
630
+ this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
631
+ if (info.instructions) {
632
+ this.ctx.chatContainer.addChild(new Spacer(1));
633
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
634
+ }
635
+ if (useManualInput) {
636
+ this.ctx.chatContainer.addChild(new Spacer(1));
637
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
638
+ }
639
+ this.ctx.ui.requestRender();
640
+ this.ctx.openInBrowser(info.url);
641
+ },
642
+ onPrompt: async (prompt: { message: string; placeholder?: string }) => {
643
+ this.ctx.chatContainer.addChild(new Spacer(1));
644
+ this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
645
+ if (prompt.placeholder) {
646
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
647
+ }
648
+ this.ctx.ui.requestRender();
649
+ const { promise, resolve } = Promise.withResolvers<string>();
650
+ const codeInput = new Input();
651
+ codeInput.onSubmit = () => {
652
+ const code = codeInput.getValue();
653
+ this.ctx.editorContainer.clear();
654
+ this.ctx.editorContainer.addChild(this.ctx.editor);
655
+ this.ctx.ui.setFocus(this.ctx.editor);
656
+ resolve(code);
657
+ };
658
+ this.ctx.editorContainer.clear();
659
+ this.ctx.editorContainer.addChild(codeInput);
660
+ this.ctx.ui.setFocus(codeInput);
661
+ this.ctx.ui.requestRender();
662
+ return promise;
663
+ },
664
+ onProgress: (message: string) => {
665
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
666
+ this.ctx.ui.requestRender();
667
+ },
668
+ onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
669
+ });
670
+ await this.ctx.session.modelRegistry.refresh();
671
+ this.ctx.chatContainer.addChild(new Spacer(1));
672
+ this.ctx.chatContainer.addChild(
673
+ new Text(theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`), 1, 0),
674
+ );
675
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
676
+ this.ctx.ui.requestRender();
677
+ } catch (error: unknown) {
678
+ this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
679
+ } finally {
680
+ if (useManualInput) {
681
+ manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
682
+ }
683
+ }
684
+ }
685
+
686
+ async #handleOAuthLogout(providerId: string): Promise<void> {
687
+ try {
688
+ await this.ctx.session.modelRegistry.authStorage.logout(providerId);
689
+ await this.ctx.session.modelRegistry.refresh();
690
+ this.ctx.chatContainer.addChild(new Spacer(1));
691
+ this.ctx.chatContainer.addChild(
692
+ new Text(theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`), 1, 0),
693
+ );
694
+ this.ctx.chatContainer.addChild(
695
+ new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
696
+ );
697
+ this.ctx.ui.requestRender();
698
+ } catch (error: unknown) {
699
+ this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
700
+ }
701
+ }
702
+
703
+ async showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
704
+ if (providerId) {
705
+ if (mode === "login") {
706
+ await this.#handleOAuthLogin(providerId);
707
+ } else {
708
+ await this.#handleOAuthLogout(providerId);
709
+ }
710
+ return;
711
+ }
712
+
621
713
  if (mode === "logout") {
622
714
  await this.#refreshOAuthProviderAuthState();
623
715
  const oauthProviders = getOAuthProviders();
@@ -635,101 +727,13 @@ export class SelectorController {
635
727
  selector = new OAuthSelectorComponent(
636
728
  mode,
637
729
  this.ctx.session.modelRegistry.authStorage,
638
- async (providerId: string) => {
730
+ async (selectedProviderId: string) => {
639
731
  selector.stopValidation();
640
732
  done();
641
733
  if (mode === "login") {
642
- this.ctx.showStatus(`Logging in to ${providerId}…`);
643
- const manualInput = this.ctx.oauthManualInput;
644
- const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
645
- try {
646
- await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
647
- onAuth: (info: { url: string; instructions?: string }) => {
648
- this.ctx.chatContainer.addChild(new Spacer(1));
649
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
650
- // Use OSC 8 hyperlink escape sequence for clickable link
651
- const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
652
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
653
- if (info.instructions) {
654
- this.ctx.chatContainer.addChild(new Spacer(1));
655
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
656
- }
657
- if (useManualInput) {
658
- this.ctx.chatContainer.addChild(new Spacer(1));
659
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
660
- }
661
- this.ctx.ui.requestRender();
662
- this.ctx.openInBrowser(info.url);
663
- },
664
- onPrompt: async (prompt: { message: string; placeholder?: string }) => {
665
- this.ctx.chatContainer.addChild(new Spacer(1));
666
- this.ctx.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0));
667
- if (prompt.placeholder) {
668
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0));
669
- }
670
- this.ctx.ui.requestRender();
671
- return new Promise<string>(resolve => {
672
- const codeInput = new Input();
673
- codeInput.onSubmit = () => {
674
- const code = codeInput.getValue();
675
- this.ctx.editorContainer.clear();
676
- this.ctx.editorContainer.addChild(this.ctx.editor);
677
- this.ctx.ui.setFocus(this.ctx.editor);
678
- resolve(code);
679
- };
680
- this.ctx.editorContainer.clear();
681
- this.ctx.editorContainer.addChild(codeInput);
682
- this.ctx.ui.setFocus(codeInput);
683
- this.ctx.ui.requestRender();
684
- });
685
- },
686
- onProgress: (message: string) => {
687
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
688
- this.ctx.ui.requestRender();
689
- },
690
- onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
691
- });
692
- // Refresh models to pick up new baseUrl (e.g., github-copilot)
693
- await this.ctx.session.modelRegistry.refresh();
694
- this.ctx.chatContainer.addChild(new Spacer(1));
695
- this.ctx.chatContainer.addChild(
696
- new Text(
697
- theme.fg("success", `${theme.status.success} Successfully logged in to ${providerId}`),
698
- 1,
699
- 0,
700
- ),
701
- );
702
- this.ctx.chatContainer.addChild(
703
- new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0),
704
- );
705
- this.ctx.ui.requestRender();
706
- } catch (error: unknown) {
707
- this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
708
- } finally {
709
- if (useManualInput) {
710
- manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
711
- }
712
- }
734
+ await this.#handleOAuthLogin(selectedProviderId);
713
735
  } else {
714
- try {
715
- await this.ctx.session.modelRegistry.authStorage.logout(providerId);
716
- // Refresh models to reset baseUrl
717
- await this.ctx.session.modelRegistry.refresh();
718
- this.ctx.chatContainer.addChild(new Spacer(1));
719
- this.ctx.chatContainer.addChild(
720
- new Text(
721
- theme.fg("success", `${theme.status.success} Successfully logged out of ${providerId}`),
722
- 1,
723
- 0,
724
- ),
725
- );
726
- this.ctx.chatContainer.addChild(
727
- new Text(theme.fg("dim", `Credentials removed from ${getAgentDbPath()}`), 1, 0),
728
- );
729
- this.ctx.ui.requestRender();
730
- } catch (error: unknown) {
731
- this.ctx.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
732
- }
736
+ await this.#handleOAuthLogout(selectedProviderId);
733
737
  }
734
738
  },
735
739
  () => {
@@ -738,9 +742,9 @@ export class SelectorController {
738
742
  this.ctx.ui.requestRender();
739
743
  },
740
744
  {
741
- validateAuth: async (providerId: string) => {
745
+ validateAuth: async (selectedProviderId: string) => {
742
746
  const apiKey = await this.ctx.session.modelRegistry.getApiKeyForProvider(
743
- providerId,
747
+ selectedProviderId,
744
748
  this.ctx.session.sessionId,
745
749
  );
746
750
  return !!apiKey;
@@ -949,7 +949,7 @@ export class InteractiveMode implements InteractiveModeContext {
949
949
  return this.#commandController.handleExportCommand(text);
950
950
  }
951
951
 
952
- handleDumpCommand(): Promise<void> {
952
+ handleDumpCommand() {
953
953
  return this.#commandController.handleDumpCommand();
954
954
  }
955
955
 
@@ -961,7 +961,7 @@ export class InteractiveMode implements InteractiveModeContext {
961
961
  return this.#commandController.handleShareCommand();
962
962
  }
963
963
 
964
- handleCopyCommand(): Promise<void> {
964
+ handleCopyCommand() {
965
965
  return this.#commandController.handleCopyCommand();
966
966
  }
967
967
 
@@ -1149,8 +1149,8 @@ export class InteractiveMode implements InteractiveModeContext {
1149
1149
  return this.#selectorController.handleResumeSession(sessionPath);
1150
1150
  }
1151
1151
 
1152
- showOAuthSelector(mode: "login" | "logout"): Promise<void> {
1153
- return this.#selectorController.showOAuthSelector(mode);
1152
+ showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void> {
1153
+ return this.#selectorController.showOAuthSelector(mode, providerId);
1154
1154
  }
1155
1155
 
1156
1156
  showHookConfirm(title: string, message: string): Promise<boolean> {
@@ -147,13 +147,13 @@ export interface InteractiveModeContext {
147
147
  // Command handling
148
148
  handleExportCommand(text: string): Promise<void>;
149
149
  handleShareCommand(): Promise<void>;
150
- handleCopyCommand(): Promise<void>;
150
+ handleCopyCommand(): void;
151
151
  handleSessionCommand(): Promise<void>;
152
152
  handleJobsCommand(): Promise<void>;
153
153
  handleUsageCommand(reports?: UsageReport[] | null): Promise<void>;
154
154
  handleChangelogCommand(showFull?: boolean): Promise<void>;
155
155
  handleHotkeysCommand(): void;
156
- handleDumpCommand(): Promise<void>;
156
+ handleDumpCommand(): void;
157
157
  handleDebugTranscriptCommand(): Promise<void>;
158
158
  handleClearCommand(): Promise<void>;
159
159
  handleForkCommand(): Promise<void>;
@@ -180,7 +180,7 @@ export interface InteractiveModeContext {
180
180
  showTreeSelector(): void;
181
181
  showSessionSelector(): void;
182
182
  handleResumeSession(sessionPath: string): Promise<void>;
183
- showOAuthSelector(mode: "login" | "logout"): Promise<void>;
183
+ showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
184
184
  showHookConfirm(title: string, message: string): Promise<boolean>;
185
185
  showDebugSelector(): void;
186
186