@gajae-code/coding-agent 0.15.2 → 0.15.3

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 (95) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/dist/types/config/autorouting-tier-map.d.ts +0 -9
  3. package/dist/types/config/file-lock.d.ts +7 -1
  4. package/dist/types/config/keybindings.d.ts +13 -3
  5. package/dist/types/config/model-registry.d.ts +5 -0
  6. package/dist/types/config/model-resolver.d.ts +1 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -1
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/question-state.d.ts +42 -2
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  11. package/dist/types/main.d.ts +1 -1
  12. package/dist/types/modes/action-registry.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -1
  14. package/dist/types/modes/components/welcome.d.ts +2 -0
  15. package/dist/types/modes/controllers/input-controller.d.ts +16 -1
  16. package/dist/types/modes/controllers/runtime-mcp-command-controller.d.ts +42 -0
  17. package/dist/types/modes/interactive-mode.d.ts +7 -0
  18. package/dist/types/modes/shared/oauth-url-copy.d.ts +9 -0
  19. package/dist/types/modes/types.d.ts +3 -0
  20. package/dist/types/modes/ui-language.d.ts +2 -1
  21. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  22. package/dist/types/sdk/broker/session-index.d.ts +18 -0
  23. package/dist/types/sdk/bus/index.d.ts +4 -1
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  26. package/dist/types/sdk/cli/session-cli.d.ts +1 -1
  27. package/dist/types/sdk/lifecycle/service.d.ts +32 -5
  28. package/dist/types/sdk/session.d.ts +5 -0
  29. package/dist/types/session/agent-session.d.ts +4 -0
  30. package/dist/types/session/fallback-chain-controller.d.ts +8 -0
  31. package/dist/types/slash-commands/helpers/aside.d.ts +54 -0
  32. package/dist/types/tools/ask.d.ts +8 -0
  33. package/dist/types/utils/clipboard.d.ts +9 -1
  34. package/package.json +7 -7
  35. package/scripts/generate-sdk-adapter-parity-manifest.ts +5 -2
  36. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  37. package/src/commands/sdk.ts +2 -2
  38. package/src/config/autorouting-tier-map.ts +0 -9
  39. package/src/config/file-lock.ts +15 -1
  40. package/src/config/keybindings.ts +13 -3
  41. package/src/config/model-registry.ts +132 -21
  42. package/src/config/model-resolver.ts +2 -1
  43. package/src/config/models-config-schema.ts +2 -2
  44. package/src/config/settings-schema.ts +13 -1
  45. package/src/coordinator/contract.ts +1 -0
  46. package/src/coordinator-mcp/question-state.ts +235 -4
  47. package/src/coordinator-mcp/server.ts +402 -2
  48. package/src/dap/client.ts +19 -8
  49. package/src/gjc-runtime/deep-interview-stage.ts +20 -1
  50. package/src/gjc-runtime/deep-interview-state.ts +3 -1
  51. package/src/gjc-runtime/session-state-lock.ts +11 -2
  52. package/src/internal-urls/docs-index.generated.ts +8 -7
  53. package/src/lsp/client.ts +39 -4
  54. package/src/main.ts +42 -11
  55. package/src/modes/action-registry.ts +7 -0
  56. package/src/modes/components/iterm-pet-transport.ts +4 -2
  57. package/src/modes/components/runtime-mcp-add-wizard.ts +38 -1
  58. package/src/modes/components/settings-selector.ts +1 -0
  59. package/src/modes/components/tool-status-header.ts +149 -41
  60. package/src/modes/components/welcome.ts +8 -0
  61. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  62. package/src/modes/controllers/input-controller.ts +141 -7
  63. package/src/modes/controllers/plan-mode-controller.ts +12 -4
  64. package/src/modes/controllers/runtime-mcp-command-controller.ts +164 -20
  65. package/src/modes/controllers/selector-controller.ts +11 -2
  66. package/src/modes/interactive-mode.ts +88 -1
  67. package/src/modes/shared/oauth-url-copy.ts +33 -0
  68. package/src/modes/types.ts +3 -0
  69. package/src/modes/ui-language.ts +44 -3
  70. package/src/runtime-mcp/oauth-flow.ts +19 -8
  71. package/src/runtime-mcp/transports/stdio.ts +27 -13
  72. package/src/sdk/acp/adapter.ts +29 -2
  73. package/src/sdk/broker/broker.ts +26 -6
  74. package/src/sdk/broker/lifecycle-ledger.ts +28 -1
  75. package/src/sdk/broker/lifecycle.ts +664 -5
  76. package/src/sdk/broker/session-index.ts +246 -0
  77. package/src/sdk/broker/transport.ts +1 -0
  78. package/src/sdk/bus/index.ts +135 -38
  79. package/src/sdk/bus/natives-augment.d.ts +20 -1
  80. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  81. package/src/sdk/bus/telegram-daemon.ts +3 -0
  82. package/src/sdk/cli/session-cli.ts +34 -7
  83. package/src/sdk/lifecycle/service.ts +166 -11
  84. package/src/sdk/mcp/server.ts +2 -1
  85. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  86. package/src/sdk/protocol/operation-registry.ts +2 -1
  87. package/src/sdk/session.ts +91 -7
  88. package/src/session/agent-session.ts +403 -56
  89. package/src/session/fallback-chain-controller.ts +17 -0
  90. package/src/setup/frictionless-onboarding.ts +4 -4
  91. package/src/slash-commands/aside-command.test.ts +366 -0
  92. package/src/slash-commands/builtin-registry.ts +19 -0
  93. package/src/slash-commands/helpers/aside.ts +299 -0
  94. package/src/tools/ask.ts +8 -0
  95. package/src/utils/clipboard.ts +37 -28
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import * as path from "node:path";
7
7
  import { resolveMCPOAuthResourceOrigin, resolveMCPOAuthTokenEndpoint } from "@gajae-code/ai/core";
8
- import { Spacer, Text } from "@gajae-code/tui";
8
+ import { matchesKey, type OverlayHandle, Spacer, Text } from "@gajae-code/tui";
9
9
  import { getMCPConfigPath, getProjectDir } from "@gajae-code/utils";
10
10
  import type { SourceMeta } from "../../capability/types";
11
11
  import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../runtime-mcp";
@@ -18,7 +18,7 @@ import {
18
18
  setServerDisabled,
19
19
  updateMCPServer,
20
20
  } from "../../runtime-mcp/config-writer";
21
- import { canonicalMCPResourceUri, MCPOAuthFlow } from "../../runtime-mcp/oauth-flow";
21
+ import { canonicalMCPResourceUri, type MCPOAuthConfig, MCPOAuthFlow } from "../../runtime-mcp/oauth-flow";
22
22
  import {
23
23
  clearSmitheryApiKey,
24
24
  createSmitheryCliAuthSession,
@@ -38,10 +38,13 @@ import type { MCPAuthConfig, MCPServerConfig } from "../../runtime-mcp/types";
38
38
  import type { OAuthCredential } from "../../session/auth-storage";
39
39
  import { shortenPath } from "../../tools/render-utils";
40
40
  import { openPath } from "../../utils/open";
41
+ import { CommandPalette, type CommandPaletteEntry } from "../components/command-palette";
41
42
  import { MCPAddWizard } from "../components/runtime-mcp-add-wizard";
42
43
  import { parseCommandArgs } from "../shared";
44
+ import { buildOAuthLoginAnchor, createOAuthUrlCopyLease } from "../shared/oauth-url-copy";
43
45
  import { theme } from "../theme/theme";
44
46
  import type { InteractiveModeContext } from "../types";
47
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
45
48
  import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
46
49
 
47
50
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
@@ -70,6 +73,18 @@ interface OAuthFlowResult {
70
73
  type MCPAddScope = "user" | "project";
71
74
  type MCPAddTransport = "http" | "sse";
72
75
 
76
+ type MCPAuthFlow = Pick<MCPOAuthFlow, "resolvedClientId" | "registeredClientSecret"> & {
77
+ login(): Promise<{ access: string; refresh: string; expires: number }>;
78
+ };
79
+
80
+ type MCPAuthCallbacks = {
81
+ onAuth(info: { url: string; instructions?: string }): void;
82
+ onProgress(message: string): void;
83
+ signal?: AbortSignal;
84
+ };
85
+
86
+ type MCPAuthFlowFactory = (config: MCPOAuthConfig, callbacks: MCPAuthCallbacks) => MCPAuthFlow;
87
+
73
88
  type MCPAddParsed = {
74
89
  initialName?: string;
75
90
  scope: MCPAddScope;
@@ -379,6 +394,46 @@ export class MCPCommandController {
379
394
  return { keyword, scope, limit, semantic };
380
395
  }
381
396
 
397
+ #openWizardOAuthCommandPalette(wizard: MCPAddWizard, keyData: string): boolean {
398
+ if (this.ctx.hasOAuthUrlForCopy?.() !== true) return false;
399
+ if (!this.ctx.keybindings.getKeys("app.commandPalette.open").some(key => matchesKey(keyData, key))) return false;
400
+
401
+ let overlayHandle: OverlayHandle | undefined;
402
+ const close = (): void => {
403
+ overlayHandle?.hide();
404
+ this.ctx.ui.setFocus(wizard);
405
+ this.ctx.ui.requestRender(true);
406
+ };
407
+ const copyEntry: CommandPaletteEntry = {
408
+ id: "action:app.clipboard.copyOAuthUrl",
409
+ label: "Copy OAuth URL",
410
+ description: "Copy the pending OAuth authorization URL",
411
+ keybinding: this.ctx.keybindings.getDisplayString("app.clipboard.copyOAuthUrl") || undefined,
412
+ handler: async () => {
413
+ await this.ctx.copyOAuthUrl();
414
+ },
415
+ };
416
+ const palette = new CommandPalette(
417
+ [copyEntry],
418
+ entry => {
419
+ close();
420
+ void Promise.resolve(entry.handler?.()).catch(error => {
421
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
422
+ });
423
+ },
424
+ close,
425
+ );
426
+ overlayHandle = this.ctx.ui.showOverlay(palette, {
427
+ anchor: "bottom-center",
428
+ width: "100%",
429
+ maxHeight: "100%",
430
+ margin: 0,
431
+ });
432
+ this.ctx.ui.setFocus(palette);
433
+ this.ctx.ui.requestRender();
434
+ return true;
435
+ }
436
+
382
437
  /**
383
438
  * Handle /mcp add - Launch interactive wizard or quick-add from args
384
439
  */
@@ -424,7 +479,7 @@ export class MCPCommandController {
424
479
 
425
480
  try {
426
481
  const oauthClientSecret = finalConfig.oauth?.clientSecret ?? "";
427
- const oauthResult = await this.#handleOAuthFlow(
482
+ const oauthResult = await this.handleOAuthFlow(
428
483
  finalConfig.url,
429
484
  oauth.authorizationUrl,
430
485
  oauth.tokenUrl,
@@ -475,7 +530,8 @@ export class MCPCommandController {
475
530
  };
476
531
 
477
532
  // Create wizard with OAuth handler and connection test
478
- const wizard = new MCPAddWizard(
533
+ let wizard: MCPAddWizard;
534
+ wizard = new MCPAddWizard(
479
535
  async (name: string, config: MCPServerConfig, scope: "user" | "project") => {
480
536
  done();
481
537
  await this.#handleWizardComplete(name, config, scope);
@@ -491,8 +547,23 @@ export class MCPCommandController {
491
547
  clientId: string,
492
548
  clientSecret: string,
493
549
  scopes: string,
550
+ signal: AbortSignal,
494
551
  ) => {
495
- return await this.#handleOAuthFlow(endpointUrl, authUrl, tokenUrl, clientId, clientSecret, scopes);
552
+ return await this.handleOAuthFlow(
553
+ endpointUrl,
554
+ authUrl,
555
+ tokenUrl,
556
+ clientId,
557
+ clientSecret,
558
+ scopes,
559
+ undefined,
560
+ undefined,
561
+ undefined,
562
+ undefined,
563
+ undefined,
564
+ undefined,
565
+ signal,
566
+ );
496
567
  },
497
568
  async (config: MCPServerConfig) => {
498
569
  return await this.#handleTestConnection(config);
@@ -500,7 +571,15 @@ export class MCPCommandController {
500
571
  () => {
501
572
  this.ctx.ui.requestRender();
502
573
  },
574
+ keyData => this.#openWizardOAuthCommandPalette(wizard, keyData),
503
575
  parsed.initialName,
576
+ credentialId => this.#removeManagedOAuthCredential(credentialId),
577
+ (credentialId, error) =>
578
+ this.ctx.showError(
579
+ `Cancelled OAuth cleanup failed for ${credentialId}; the credential may remain stored. ${
580
+ error instanceof Error ? error.message : String(error)
581
+ }`,
582
+ ),
504
583
  );
505
584
 
506
585
  // Replace editor with wizard
@@ -517,7 +596,8 @@ export class MCPCommandController {
517
596
  /**
518
597
  * Handle OAuth authentication flow for MCP server
519
598
  */
520
- async #handleOAuthFlow(
599
+ /** Internal OAuth entry point; injectable flow construction keeps controller lifecycle coverage deterministic. */
600
+ async handleOAuthFlow(
521
601
  endpointUrl: string,
522
602
  authUrl: string,
523
603
  tokenUrl: string,
@@ -528,6 +608,9 @@ export class MCPCommandController {
528
608
  callbackPath?: string,
529
609
  redirectUri?: string,
530
610
  oauthMeta?: { issuer?: string; issuerResponseIssSupported?: boolean },
611
+ createFlow: MCPAuthFlowFactory = (config, callbacks) => new MCPOAuthFlow(config, callbacks),
612
+ openBrowser: (url: string) => void = openPath,
613
+ signal?: AbortSignal,
531
614
  ): Promise<OAuthFlowResult> {
532
615
  const authStorage = this.ctx.session.modelRegistry.authStorage;
533
616
  let parsedAuthUrl: URL;
@@ -546,10 +629,31 @@ export class MCPCommandController {
546
629
 
547
630
  const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
548
631
  const resolvedClientSecret = clientSecret.trim() || undefined;
632
+ const copyOAuthUrlKey = this.ctx.keybindings?.getDisplayString?.("app.clipboard.copyOAuthUrl") ?? "";
633
+ const copyOAuthUrlHint = copyOAuthUrlKey
634
+ ? `Use ${copyOAuthUrlKey} or command palette → Copy OAuth URL to copy this exact URL:`
635
+ : "Use command palette → Copy OAuth URL to copy this exact URL:";
636
+ const oauthUrlCopyLease = createOAuthUrlCopyLease(this.ctx);
637
+ const timeoutController = new AbortController();
638
+ const userController = new AbortController();
639
+ const flowSignal = AbortSignal.any([
640
+ userController.signal,
641
+ timeoutController.signal,
642
+ ...(signal ? [signal] : []),
643
+ ]);
644
+ let flowFinished = false;
645
+ const cancellationUnsubscribe =
646
+ signal === undefined && typeof this.ctx.ui.addInputListener === "function"
647
+ ? this.ctx.ui.addInputListener(data => {
648
+ if (data !== "\x03" && !matchesAppInterrupt(data)) return;
649
+ userController.abort(new Error("OAuth flow cancelled"));
650
+ return { consume: true };
651
+ })
652
+ : undefined;
549
653
 
550
654
  try {
551
655
  // Create OAuth flow
552
- const flow = new MCPOAuthFlow(
656
+ const flow = createFlow(
553
657
  {
554
658
  authorizationUrl: authUrl,
555
659
  tokenUrl: tokenEndpoint,
@@ -569,6 +673,10 @@ export class MCPCommandController {
569
673
  },
570
674
  {
571
675
  onAuth: (info: { url: string; instructions?: string }) => {
676
+ if (flowFinished || flowSignal.aborted) return;
677
+ queueMicrotask(() => {
678
+ if (!flowFinished && !flowSignal.aborted) oauthUrlCopyLease.replace(info.url);
679
+ });
572
680
  // Show auth URL prominently in chat
573
681
  this.ctx.chatContainer.addChild(new Spacer(1));
574
682
  this.ctx.chatContainer.addChild(
@@ -593,7 +701,7 @@ export class MCPCommandController {
593
701
  this.ctx.ui.requestRender();
594
702
  // Try to open browser automatically
595
703
  try {
596
- openPath(info.url);
704
+ openBrowser(info.url);
597
705
 
598
706
  // Show confirmation that browser should open
599
707
  this.ctx.chatContainer.addChild(new Spacer(1));
@@ -604,10 +712,10 @@ export class MCPCommandController {
604
712
  this.ctx.chatContainer.addChild(
605
713
  new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0),
606
714
  );
715
+ this.ctx.chatContainer.addChild(new Text(theme.fg("success", copyOAuthUrlHint), 1, 0));
607
716
  this.ctx.chatContainer.addChild(
608
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
717
+ new Text(theme.fg("accent", buildOAuthLoginAnchor(info.url)), 1, 0),
609
718
  );
610
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
611
719
  this.ctx.ui.requestRender();
612
720
  } catch (_error) {
613
721
  // Show error if browser doesn't open
@@ -615,10 +723,10 @@ export class MCPCommandController {
615
723
  this.ctx.chatContainer.addChild(
616
724
  new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0),
617
725
  );
726
+ this.ctx.chatContainer.addChild(new Text(theme.fg("success", copyOAuthUrlHint), 1, 0));
618
727
  this.ctx.chatContainer.addChild(
619
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
728
+ new Text(theme.fg("accent", buildOAuthLoginAnchor(info.url)), 1, 0),
620
729
  );
621
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
622
730
  this.ctx.ui.requestRender();
623
731
  }
624
732
  },
@@ -627,11 +735,26 @@ export class MCPCommandController {
627
735
  this.ctx.chatContainer.addChild(new Text(theme.fg("muted", message), 1, 0));
628
736
  this.ctx.ui.requestRender();
629
737
  },
738
+ signal: flowSignal,
630
739
  },
631
740
  );
632
741
 
633
742
  // Execute OAuth flow with 5 minute timeout
634
- const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
743
+ const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
744
+ const timeoutId = setTimeout(
745
+ () => {
746
+ timeoutController.abort(new Error("OAuth flow timed out after 5 minutes"));
747
+ rejectTimeout(new Error("OAuth flow timed out after 5 minutes"));
748
+ },
749
+ 5 * 60 * 1000,
750
+ );
751
+ let credentials: { access: string; refresh: string; expires: number };
752
+ try {
753
+ credentials = await Promise.race([flow.login(), timeoutPromise]);
754
+ } finally {
755
+ flowFinished = true;
756
+ clearTimeout(timeoutId);
757
+ }
635
758
 
636
759
  this.ctx.chatContainer.addChild(new Spacer(1));
637
760
  this.ctx.chatContainer.addChild(new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0));
@@ -641,6 +764,7 @@ export class MCPCommandController {
641
764
  const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
642
765
 
643
766
  // Store credentials in auth storage
767
+ flowSignal.throwIfAborted();
644
768
  const oauthCredential: OAuthCredential = {
645
769
  type: "oauth",
646
770
  ...credentials,
@@ -652,6 +776,10 @@ export class MCPCommandController {
652
776
 
653
777
  // Store under a synthetic provider name
654
778
  await authStorage.set(credentialId, oauthCredential);
779
+ if (flowSignal.aborted) {
780
+ await authStorage.remove(credentialId);
781
+ flowSignal.throwIfAborted();
782
+ }
655
783
 
656
784
  return {
657
785
  credentialId,
@@ -674,6 +802,9 @@ export class MCPCommandController {
674
802
  } else {
675
803
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
676
804
  }
805
+ } finally {
806
+ oauthUrlCopyLease.release();
807
+ cancellationUnsubscribe?.();
677
808
  }
678
809
  }
679
810
 
@@ -1363,7 +1494,7 @@ export class MCPCommandController {
1363
1494
 
1364
1495
  this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1365
1496
 
1366
- const oauthResult = await this.#handleOAuthFlow(
1497
+ const oauthResult = await this.handleOAuthFlow(
1367
1498
  found.config.url,
1368
1499
  oauth.authorizationUrl,
1369
1500
  oauth.tokenUrl,
@@ -1705,6 +1836,14 @@ export class MCPCommandController {
1705
1836
 
1706
1837
  async #handleSmitheryBrowserLogin(): Promise<boolean> {
1707
1838
  const session = await createSmitheryCliAuthSession();
1839
+ const oauthUrlCopyLease = createOAuthUrlCopyLease(this.ctx);
1840
+ const abortController = new AbortController();
1841
+ const interruptUnsubscribe = this.ctx.ui.addInputListener?.(data => {
1842
+ if (data !== "\x03" && !matchesAppInterrupt(data)) return;
1843
+ abortController.abort(new Error("Smithery authorization cancelled."));
1844
+ return { consume: true };
1845
+ });
1846
+ oauthUrlCopyLease.replace(session.authUrl);
1708
1847
  const fallbackLoginUrl = getSmitheryLoginUrl();
1709
1848
  this.#showMessage(
1710
1849
  [
@@ -1712,7 +1851,7 @@ export class MCPCommandController {
1712
1851
  theme.bold("Smithery Login"),
1713
1852
  theme.fg("muted", "Browser authorization started. Complete auth in your browser."),
1714
1853
  theme.fg("dim", "Authorize URL:"),
1715
- theme.fg("accent", session.authUrl),
1854
+ theme.fg("accent", buildOAuthLoginAnchor(session.authUrl)),
1716
1855
  theme.fg("dim", `Fallback: ${fallbackLoginUrl}`),
1717
1856
  "",
1718
1857
  ].join("\n"),
@@ -1723,11 +1862,16 @@ export class MCPCommandController {
1723
1862
  // URL is already shown above.
1724
1863
  }
1725
1864
 
1726
- const apiKey = await this.#waitForSmitheryCliApiKey(session.sessionId, new AbortController().signal);
1727
- await this.#validateSmitheryApiKey(apiKey);
1728
- await saveSmitheryApiKey(apiKey);
1729
- this.ctx.showStatus("Smithery API key saved.");
1730
- return true;
1865
+ try {
1866
+ const apiKey = await this.#waitForSmitheryCliApiKey(session.sessionId, abortController.signal);
1867
+ await this.#validateSmitheryApiKey(apiKey);
1868
+ await saveSmitheryApiKey(apiKey);
1869
+ this.ctx.showStatus("Smithery API key saved.");
1870
+ return true;
1871
+ } finally {
1872
+ oauthUrlCopyLease.release();
1873
+ interruptUnsubscribe?.();
1874
+ }
1731
1875
  }
1732
1876
 
1733
1877
  async #promptSmitheryLogin(reason: string): Promise<boolean> {
@@ -213,6 +213,7 @@ import { TreeSelectorComponent } from "../components/tree-selector";
213
213
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
214
214
  import type { JobsObserver } from "../jobs-observer";
215
215
  import type { SessionObserverRegistry } from "../session-observer-registry";
216
+ import { buildOAuthLoginAnchor, createOAuthUrlCopyLease } from "../shared/oauth-url-copy";
216
217
  import type { TasksAggregator } from "../tasks-aggregator";
217
218
  import type { TranscriptItemRegistry } from "../transcript-item-registry";
218
219
  import { acquireResumeProgressLease, type ResumeProgressLease } from "../utils/ui-helpers";
@@ -3475,6 +3476,11 @@ export class SelectorController {
3475
3476
  this.ctx.showStatus(`Logging in to ${providerId}…`);
3476
3477
  const manualInput = this.ctx.oauthManualInput;
3477
3478
  const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
3479
+ const copyOAuthUrlKey = this.ctx.keybindings?.getDisplayString?.("app.clipboard.copyOAuthUrl") ?? "";
3480
+ const copyOAuthUrlHint = copyOAuthUrlKey
3481
+ ? `${copyOAuthUrlKey} or command palette → Copy OAuth URL copies the URL exactly.`
3482
+ : "Command palette → Copy OAuth URL copies the URL exactly.";
3483
+ const oauthUrlCopyLease = createOAuthUrlCopyLease(this.ctx);
3478
3484
  if (providerId === "opencodex") {
3479
3485
  this.ctx.showStatus("Checking the local OpenCodex proxy…");
3480
3486
  }
@@ -3483,10 +3489,12 @@ export class SelectorController {
3483
3489
  providerId as OAuthProvider,
3484
3490
  {
3485
3491
  onAuth: (info: { url: string; instructions?: string }) => {
3492
+ oauthUrlCopyLease.replace(info.url);
3486
3493
  this.ctx.chatContainer.addChild(new Spacer(1));
3487
- this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
3488
- const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
3494
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", buildOAuthLoginAnchor(info.url)), 1, 0));
3495
+ const hyperlink = buildOAuthLoginAnchor(info.url, "Click here to login");
3489
3496
  this.ctx.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
3497
+ this.ctx.chatContainer.addChild(new Text(theme.fg("muted", copyOAuthUrlHint), 1, 0));
3490
3498
  if (info.instructions) {
3491
3499
  this.ctx.chatContainer.addChild(new Spacer(1));
3492
3500
  this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
@@ -3551,6 +3559,7 @@ export class SelectorController {
3551
3559
  } catch (error: unknown) {
3552
3560
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
3553
3561
  } finally {
3562
+ oauthUrlCopyLease.release();
3554
3563
  if (useManualInput) {
3555
3564
  manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
3556
3565
  }
@@ -431,6 +431,7 @@ export class InteractiveMode implements InteractiveModeContext {
431
431
  hookEditor: HookEditorComponent | undefined = undefined;
432
432
  lastStatusSpacer: Spacer | undefined = undefined;
433
433
  lastStatusText: Text | undefined = undefined;
434
+ #oauthUrlForCopyLeases: Array<{ token: symbol; url: string }> = [];
434
435
  fileSlashCommands: Set<string> = new Set();
435
436
  skillCommands: Map<string, Skill> = new Map();
436
437
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
@@ -442,6 +443,37 @@ export class InteractiveMode implements InteractiveModeContext {
442
443
  #cleanupUnsubscribe?: () => void;
443
444
  #itermPetTransport?: ItermPetTransport;
444
445
 
446
+ waitForAgentEnd(): { promise: Promise<void>; dispose: () => void } {
447
+ const deferred = Promise.withResolvers<void>();
448
+ let disposed = false;
449
+ let agentStarted = false;
450
+ let unsubscribe: (() => void) | undefined;
451
+ const dispose = () => {
452
+ if (disposed) return;
453
+ disposed = true;
454
+ unsubscribe?.();
455
+ unsubscribe = undefined;
456
+ };
457
+ const listener = (event: AgentSessionEvent) => {
458
+ if (disposed) return;
459
+ if (event.type === "agent_start") {
460
+ agentStarted = true;
461
+ return;
462
+ }
463
+ if (event.type !== "agent_end" || !agentStarted) return;
464
+ dispose();
465
+ deferred.resolve();
466
+ };
467
+
468
+ const subscribedUnsubscribe = this.session.subscribe(listener);
469
+ unsubscribe = subscribedUnsubscribe;
470
+ if (disposed) {
471
+ subscribedUnsubscribe();
472
+ }
473
+
474
+ return { promise: deferred.promise, dispose };
475
+ }
476
+
445
477
  /**
446
478
  * Pet-unavailable warning text. The iTerm transport reason is parenthesized
447
479
  * only when a transport actually reported one; non-iTerm terminals and a
@@ -808,6 +840,7 @@ export class InteractiveMode implements InteractiveModeContext {
808
840
  rightGutterWidth: COMPOSER_RIGHT_GUTTER_WIDTH,
809
841
  collapseChangelog: settings.get("collapseChangelog"),
810
842
  keyDisplayContext: this.#keyDisplayContext,
843
+ reducedMotion: settings.get("startup.skipLogoAnimation") === true,
811
844
  },
812
845
  );
813
846
 
@@ -1147,9 +1180,16 @@ export class InteractiveMode implements InteractiveModeContext {
1147
1180
  }
1148
1181
 
1149
1182
  async getUserInput(): Promise<SubmittedUserInput> {
1183
+ if (this.#stopped || this.#isShuttingDown) {
1184
+ throw Object.assign(new Error("Interactive mode stopped"), { code: "cancelled" });
1185
+ }
1150
1186
  if (this.session.getGoalModeState()?.mode === "exiting") {
1151
1187
  await this.#goalModeController.beforeGetUserInput();
1152
1188
  }
1189
+ const deferredSubmission = this.#inputController.takeDeferredSubmission();
1190
+ if (deferredSubmission) {
1191
+ return deferredSubmission;
1192
+ }
1153
1193
  const { promise, resolve, reject } = Promise.withResolvers<SubmittedUserInput>();
1154
1194
  let unsubscribeStop = () => {};
1155
1195
  unsubscribeStop = this.onStop(() => {
@@ -1584,6 +1624,7 @@ export class InteractiveMode implements InteractiveModeContext {
1584
1624
  stop(): void {
1585
1625
  const wasInitialized = this.isInitialized;
1586
1626
  this.#stopped = true;
1627
+ this.#inputController.discardDeferredSubmission();
1587
1628
  for (const listener of this.#stopListeners) {
1588
1629
  try {
1589
1630
  listener();
@@ -1647,6 +1688,16 @@ export class InteractiveMode implements InteractiveModeContext {
1647
1688
  // `/btw` owns the shared composer while its panel is open. Never persist a
1648
1689
  // side-chat draft or pending side-chat images into the main-session draft.
1649
1690
  const hadActiveBtw = this.#btwController.hasOpenPanel();
1691
+ const deferredSubmission = this.#inputController.takeDeferredSubmissionForShutdown();
1692
+ if (
1693
+ deferredSubmission &&
1694
+ !hadActiveBtw &&
1695
+ this.editor.getText().length === 0 &&
1696
+ this.pendingImages.length === 0
1697
+ ) {
1698
+ this.editor.setText(deferredSubmission.text);
1699
+ this.pendingImages = deferredSubmission.images ? [...deferredSubmission.images] : [];
1700
+ }
1650
1701
  const draftText = selectShutdownDraft(this.editor.getText(), hadActiveBtw);
1651
1702
  if (hadActiveBtw) this.pendingImages = [];
1652
1703
  this.#btwController.dispose();
@@ -1788,6 +1839,42 @@ export class InteractiveMode implements InteractiveModeContext {
1788
1839
  this.#uiHelpers.showWarning(message);
1789
1840
  }
1790
1841
 
1842
+ beginOAuthUrlForCopy(url: string): () => void {
1843
+ const token = Symbol("oauth-url-copy");
1844
+ this.#oauthUrlForCopyLeases.push({ token, url });
1845
+ return () => {
1846
+ const index = this.#oauthUrlForCopyLeases.findIndex(lease => lease.token === token);
1847
+ if (index === -1) return;
1848
+ this.#oauthUrlForCopyLeases.splice(index, 1);
1849
+ };
1850
+ }
1851
+
1852
+ hasOAuthUrlForCopy(): boolean {
1853
+ return this.#oauthUrlForCopyLeases.length > 0;
1854
+ }
1855
+
1856
+ async copyOAuthUrl(): Promise<void> {
1857
+ const pending = this.#oauthUrlForCopyLeases.at(-1);
1858
+ if (pending === undefined) {
1859
+ this.showStatus("No OAuth URL is available to copy.");
1860
+ return;
1861
+ }
1862
+ try {
1863
+ const outcome = await copyToClipboard(pending.url);
1864
+ if (outcome.status === "verified") {
1865
+ this.showStatus("OAuth URL copied to clipboard.");
1866
+ } else if (outcome.reason.includes("failed") || outcome.reason.includes("rejected")) {
1867
+ this.showWarning(
1868
+ `OAuth URL delivery was not confirmed (${outcome.transport}). Use the visible link or copy it manually.`,
1869
+ );
1870
+ } else {
1871
+ this.showStatus(`OAuth URL delivery attempted via ${outcome.transport}; verify it arrived before pasting.`);
1872
+ }
1873
+ } catch {
1874
+ this.showWarning("Failed to copy OAuth URL to clipboard.");
1875
+ }
1876
+ }
1877
+
1791
1878
  #handleLspStartupEvent(event: LspStartupEvent): void {
1792
1879
  this.#updateWelcomeLspServers();
1793
1880
 
@@ -1864,7 +1951,7 @@ export class InteractiveMode implements InteractiveModeContext {
1864
1951
  workingMessage => renderWorkingMessage(workingMessage, this.#getWorkingMessageAccent()),
1865
1952
  message,
1866
1953
  getSymbolTheme().spinnerFrames,
1867
- { timeDependentColor: true },
1954
+ { timeDependentColor: true, renderScope: "layout" },
1868
1955
  );
1869
1956
  this.statusContainer.addChild(this.loadingAnimation);
1870
1957
  }
@@ -0,0 +1,33 @@
1
+ import { isHyperlinkEnabled } from "../../tui/hyperlink";
2
+
3
+ export interface OAuthUrlCopyLeaseHost {
4
+ beginOAuthUrlForCopy(url: string): () => void;
5
+ }
6
+
7
+ export interface OAuthUrlCopyLease {
8
+ replace(url: string): void;
9
+ release(): void;
10
+ }
11
+
12
+ export function buildOAuthLoginAnchor(url: string, label: string = url, hyperlinks = isHyperlinkEnabled()): string {
13
+ return hyperlinks ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
14
+ }
15
+
16
+ export function createOAuthUrlCopyLease(host: OAuthUrlCopyLeaseHost): OAuthUrlCopyLease {
17
+ let releaseCurrent: (() => void) | undefined;
18
+ let released = false;
19
+
20
+ return {
21
+ replace(url: string): void {
22
+ if (released) return;
23
+ releaseCurrent?.();
24
+ releaseCurrent = host.beginOAuthUrlForCopy(url);
25
+ },
26
+ release(): void {
27
+ if (released) return;
28
+ released = true;
29
+ releaseCurrent?.();
30
+ releaseCurrent = undefined;
31
+ },
32
+ };
33
+ }
@@ -270,6 +270,9 @@ export interface InteractiveModeContext {
270
270
  showStatus(message: string, options?: { dim?: boolean }): void;
271
271
  showError(message: string): void;
272
272
  showWarning(message: string): void;
273
+ beginOAuthUrlForCopy(url: string): () => void;
274
+ hasOAuthUrlForCopy(): boolean;
275
+ copyOAuthUrl(): Promise<void>;
273
276
  notifyConfigChanged?: () => Promise<void> | void;
274
277
  showNewVersionNotification(newVersion: string): void;
275
278
  clearEditor(): void;
@@ -1,4 +1,4 @@
1
- export const UI_LANGUAGES = ["en", "ko"] as const;
1
+ export const UI_LANGUAGES = ["en", "ko", "ja"] as const;
2
2
 
3
3
  export type UiLanguage = (typeof UI_LANGUAGES)[number];
4
4
 
@@ -23,6 +23,7 @@ const ENGLISH_STRINGS = {
23
23
  "settings.language.description": "Language for human-facing interactive UI text",
24
24
  "settings.language.english": "English",
25
25
  "settings.language.korean": "Korean (한국어)",
26
+ "settings.language.japanese": "Japanese (日本語)",
26
27
  "language.current": "Current UI language:",
27
28
  "language.changed": "UI language changed to",
28
29
  "language.unknown": "Unknown language",
@@ -51,24 +52,59 @@ const KOREAN_STRINGS: Record<UiStringKey, string> = {
51
52
  "settings.language.description": "사람이 읽는 대화형 UI 텍스트의 언어",
52
53
  "settings.language.english": "English",
53
54
  "settings.language.korean": "한국어",
55
+ "settings.language.japanese": "日本語",
54
56
  "language.current": "현재 UI 언어:",
55
57
  "language.changed": "UI 언어를 다음으로 변경했습니다:",
56
58
  "language.unknown": "알 수 없는 언어",
57
59
  };
58
60
 
61
+ const JAPANESE_STRINGS: Record<UiStringKey, string> = {
62
+ "settings.title": "設定",
63
+ "settings.navigationHint": "(Tab キーで切り替え)",
64
+ "settings.selectHint": " Enter: 選択 · Esc: 戻る",
65
+ "settings.preview": "プレビュー:",
66
+ "settings.tab.appearance": "外観",
67
+ "settings.tab.model": "モデル",
68
+ "settings.tab.interaction": "操作",
69
+ "settings.tab.context": "コンテキスト",
70
+ "settings.tab.memory": "メモリ",
71
+ "settings.tab.editing": "編集",
72
+ "settings.tab.tools": "ツール",
73
+ "settings.tab.tasks": "タスク",
74
+ "settings.tab.providers": "プロバイダー",
75
+ "settings.tab.notifications": "通知",
76
+ "settings.tab.plugins": "プラグイン",
77
+ "settings.tab.gjcBundles": "GJC バンドル",
78
+ "settings.language.label": "言語",
79
+ "settings.language.description": "対話型 UI テキストに使う言語",
80
+ "settings.language.english": "English",
81
+ "settings.language.korean": "한국어",
82
+ "settings.language.japanese": "日本語",
83
+ "language.current": "現在の UI 言語:",
84
+ "language.changed": "UI 言語を次に変更しました:",
85
+ "language.unknown": "不明な言語",
86
+ };
87
+
88
+ const STRINGS: Record<UiLanguage, Record<UiStringKey, string>> = {
89
+ en: ENGLISH_STRINGS,
90
+ ko: KOREAN_STRINGS,
91
+ ja: JAPANESE_STRINGS,
92
+ };
93
+
59
94
  /** User selection is authoritative; invalid or unavailable values deterministically fall back to English. */
60
95
  export function resolveUiLanguage(value: unknown): UiLanguage {
61
- return value === "ko" ? "ko" : "en";
96
+ return value === "ko" || value === "ja" ? value : "en";
62
97
  }
63
98
 
64
99
  export function uiString(language: unknown, key: UiStringKey): string {
65
- return resolveUiLanguage(language) === "ko" ? KOREAN_STRINGS[key] : ENGLISH_STRINGS[key];
100
+ return STRINGS[resolveUiLanguage(language)][key];
66
101
  }
67
102
 
68
103
  /** Endonym shown by `/language` and the language submenu. */
69
104
  export const UI_LANGUAGE_LABELS: Record<UiLanguage, string> = {
70
105
  en: "English",
71
106
  ko: "한국어",
107
+ ja: "日本語",
72
108
  };
73
109
 
74
110
  /** Spellings accepted by `/language`; the canonical codes stay authoritative. */
@@ -81,6 +117,11 @@ const UI_LANGUAGE_ALIASES: Readonly<Record<string, UiLanguage>> = {
81
117
  kor: "ko",
82
118
  korean: "ko",
83
119
  한국어: "ko",
120
+ ja: "ja",
121
+ jp: "ja",
122
+ jpn: "ja",
123
+ japanese: "ja",
124
+ 日本語: "ja",
84
125
  };
85
126
 
86
127
  export function parseUiLanguage(value: string): UiLanguage | undefined {