@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
package/src/lsp/client.ts CHANGED
@@ -23,6 +23,8 @@ const clients = new Map<string, LspClient>();
23
23
  const killedClients = new WeakSet<LspClient>();
24
24
  const clientLocks = new Map<string, Promise<LspClient>>();
25
25
  const fileOperationLocks = new Map<string, Promise<void>>();
26
+ const initializingClients = new Set<LspClient>();
27
+ let shutdownGeneration = 0;
26
28
  const transportClosedErrors = new WeakMap<LspClient, Error>();
27
29
  const LSP_TRANSPORT_CLOSED_MESSAGE = "LSP transport closed";
28
30
  let lspCleanupOwner: (() => void) | undefined;
@@ -280,7 +282,13 @@ async function writeMessage(
280
282
  if (terminalError) throw terminalError;
281
283
 
282
284
  try {
283
- client.proc.stdin.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
285
+ // FileSink.write() returns `number | Promise<number>`: under backpressure
286
+ // (e.g. a large didOpen/didChange payload) it returns a promise whose
287
+ // rejection — typically EPIPE when the server dies mid-write — would
288
+ // otherwise be discarded here and surface as an unhandled rejection that
289
+ // takes down the whole process. Await it so every failure mode reaches
290
+ // the transport-closed mapping below.
291
+ await client.proc.stdin.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
284
292
  await client.proc.stdin.flush();
285
293
  } catch (error) {
286
294
  if (isKnownSinkPeerClosedError(error)) {
@@ -509,6 +517,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
509
517
  // Create new client with lock
510
518
  let clientPromise!: Promise<LspClient>;
511
519
  clientPromise = (async () => {
520
+ const creationGeneration = shutdownGeneration;
512
521
  const baseCommand = config.resolvedCommand ?? config.command;
513
522
  const baseArgs = config.args ?? [];
514
523
 
@@ -516,6 +525,9 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
516
525
  const { command, args, env } = isLspmuxSupported(baseCommand)
517
526
  ? await getLspmuxCommand(baseCommand, baseArgs, cwd)
518
527
  : { command: baseCommand, args: baseArgs };
528
+ if (creationGeneration !== shutdownGeneration) {
529
+ throw new Error("LSP client shutdown");
530
+ }
519
531
 
520
532
  const owner = spawnOwnedProcess([command, ...args], {
521
533
  cwd,
@@ -561,7 +573,12 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
561
573
  killedClients.add(client);
562
574
  return originalKill(...args);
563
575
  };
564
- clients.set(key, client);
576
+ initializingClients.add(client);
577
+ if (creationGeneration !== shutdownGeneration) {
578
+ initializingClients.delete(client);
579
+ await shutdownClientInstance(client);
580
+ throw new Error("LSP client shutdown");
581
+ }
565
582
 
566
583
  // Register crash recovery - remove client on process exit
567
584
  proc.exited.then(async () => {
@@ -636,15 +653,26 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
636
653
  ensureLspCleanup();
637
654
  const terminalError = transportClosedErrors.get(client);
638
655
  if (terminalError) throw terminalError;
656
+ if (creationGeneration !== shutdownGeneration) {
657
+ throw new Error("LSP client shutdown");
658
+ }
659
+
660
+ // Publish to the cache only after the handshake completes: callers that
661
+ // hit the `clients` map must never observe a client whose initialize is
662
+ // still in flight. Concurrent callers instead share `clientLocks` and
663
+ // wait for initialization, so a server that dies mid-handshake rejects
664
+ // every waiter instead of handing them a transport that is about to
665
+ // break underneath them.
666
+ clients.set(key, client);
639
667
 
640
668
  return client;
641
669
  } catch (err) {
642
670
  // Clean up on initialization failure
643
- deleteCachedClient(key, client);
644
671
  deleteClientLock(key, clientPromise);
645
672
  await shutdownClientInstance(client);
646
673
  throw err;
647
674
  } finally {
675
+ initializingClients.delete(client);
648
676
  deleteClientLock(key, clientPromise);
649
677
  }
650
678
  })();
@@ -999,11 +1027,18 @@ export async function sendNotification(client: LspClient, method: string, params
999
1027
  */
1000
1028
  export async function shutdownAll(): Promise<void> {
1001
1029
  stopIdleChecker();
1030
+ shutdownGeneration += 1;
1031
+ const inFlightPromises = Array.from(clientLocks.values());
1032
+ const initializingToShutdown = Array.from(initializingClients);
1002
1033
  clientLocks.clear();
1003
1034
  fileOperationLocks.clear();
1004
1035
  const clientsToShutdown = Array.from(clients.values());
1005
1036
  clients.clear();
1006
- await Promise.allSettled(clientsToShutdown.map(client => shutdownClientInstance(client)));
1037
+ await Promise.allSettled([
1038
+ ...clientsToShutdown.map(client => shutdownClientInstance(client)),
1039
+ ...initializingToShutdown.map(client => shutdownClientInstance(client)),
1040
+ ...inFlightPromises,
1041
+ ]);
1007
1042
  }
1008
1043
 
1009
1044
  /** Status of an LSP server */
package/src/main.ts CHANGED
@@ -343,7 +343,11 @@ export interface InteractiveModeNotify {
343
343
  export async function submitInteractiveInput(
344
344
  mode: Pick<
345
345
  InteractiveMode,
346
- "markPendingSubmissionStarted" | "finishPendingSubmission" | "showError" | "checkShutdownRequested"
346
+ | "markPendingSubmissionStarted"
347
+ | "finishPendingSubmission"
348
+ | "showError"
349
+ | "checkShutdownRequested"
350
+ | "waitForAgentEnd"
347
351
  >,
348
352
  session: Pick<AgentSession, "prompt" | "promptCustomMessage">,
349
353
  input: SubmittedUserInput,
@@ -357,15 +361,35 @@ export async function submitInteractiveInput(
357
361
  if (!input.started && !mode.markPendingSubmissionStarted(input)) {
358
362
  return;
359
363
  }
360
- if (input.customType) {
361
- await session.promptCustomMessage({
362
- customType: input.customType,
363
- content: input.text,
364
- display: input.display ?? false,
365
- attribution: "agent",
366
- });
367
- } else {
368
- await session.prompt(input.text, { images: input.images });
364
+ const agentEnd = mode.waitForAgentEnd();
365
+ try {
366
+ const prompt = input.customType
367
+ ? session.promptCustomMessage({
368
+ customType: input.customType,
369
+ content: input.text,
370
+ display: input.display ?? false,
371
+ attribution: "agent",
372
+ })
373
+ : session.prompt(input.text, { images: input.images });
374
+ const promptResult = prompt.then(
375
+ () => ({ type: "prompt" as const }),
376
+ (error: unknown) => ({ type: "error" as const, error }),
377
+ );
378
+ const result = await Promise.race([
379
+ promptResult,
380
+ agentEnd.promise.then(() => ({ type: "agent_end" as const })),
381
+ ]);
382
+ if (result.type === "error") {
383
+ const errorMessage = result.error instanceof Error ? result.error.message : "Unknown error occurred";
384
+ mode.showError(errorMessage);
385
+ } else if (result.type === "agent_end") {
386
+ void prompt.catch((error: unknown) => {
387
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
388
+ mode.showError(errorMessage);
389
+ });
390
+ }
391
+ } finally {
392
+ agentEnd.dispose();
369
393
  }
370
394
  } catch (error: unknown) {
371
395
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -1698,8 +1722,15 @@ export async function runRootCommand(
1698
1722
  );
1699
1723
  },
1700
1724
  };
1725
+ let rootStartupCacheAdmissionAttempted = false;
1701
1726
  sessionOptions.authStorage = authStorage;
1702
1727
  sessionOptions.modelRegistry = modelRegistry;
1728
+ sessionOptions.modelRegistryStartupMutation = {
1729
+ owner: "cli-root",
1730
+ onAttempt: () => {
1731
+ rootStartupCacheAdmissionAttempted = true;
1732
+ },
1733
+ };
1703
1734
  sessionOptions.hasUI = isInteractive;
1704
1735
  sessionOptions.notificationHostModeSupported = isInteractive;
1705
1736
  sessionOptions.sdkHostModeSupported = isInteractive;
@@ -1818,7 +1849,7 @@ export async function runRootCommand(
1818
1849
  // discovery arms; running these concurrently contends for the event loop and stretches
1819
1850
  // every parallel arm by ~30ms. Startup model profiles do their own foreground refresh
1820
1851
  // before activation so project-scoped defaults can resolve freshly discovered models.
1821
- if (!context?.skipPostCreateModelRefresh) {
1852
+ if (!context?.skipPostCreateModelRefresh && !rootStartupCacheAdmissionAttempted) {
1822
1853
  modelRegistry.refreshInBackground();
1823
1854
  }
1824
1855
  return result;
@@ -48,6 +48,7 @@ export const APP_ACTION_METADATA: readonly ActionMetadata[] = [
48
48
  action("app.clipboard.pasteText", "Paste text from configured clipboard", "Clipboard", ["composer"]),
49
49
  action("app.clipboard.copyLine", "Copy line", "Clipboard", ["composer"]),
50
50
  action("app.clipboard.copyPrompt", "Copy prompt", "Clipboard", ["composer"]),
51
+ action("app.clipboard.copyOAuthUrl", "Copy OAuth URL", "Clipboard", ["composer"]),
51
52
  action("app.session.new", "New session", "Session", ["composer"]),
52
53
  action("app.session.tree", "Session tree", "Session", ["composer"]),
53
54
  action("app.session.fork", "Branch from message", "Session", ["composer"]),
@@ -71,6 +72,7 @@ export const APP_ACTION_METADATA: readonly ActionMetadata[] = [
71
72
  action("app.transcript.nextTurn", "Next transcript turn", "Transcript", ["composer"]),
72
73
  action("app.mode.cycle", "Cycle mode", "Mode", ["composer"]),
73
74
  action("app.tasks.toggle", "Toggle tasks pane", "Jobs", ["composer"]),
75
+ action("app.todo.toggle", "Toggle todo expansion", "Tasks", ["composer"]),
74
76
  action("app.queue.togglePane", "Toggle queue pane", "Messages", ["composer"]),
75
77
  action("app.message.sendNow", "Send message now", "Messages", ["composer"]),
76
78
  ];
@@ -163,4 +165,9 @@ export class ActionRegistry<Context> {
163
165
  if (group) this.#busyGroups.delete(group);
164
166
  }
165
167
  }
168
+
169
+ async executeFresh(id: AppKeybinding): Promise<boolean> {
170
+ this.#availability.delete(id);
171
+ return this.execute(id);
172
+ }
166
173
  }
@@ -122,11 +122,13 @@ export function isItermCandidate(
122
122
  ): boolean {
123
123
  if (!tty) return false;
124
124
  const ci = env.CI?.trim().toLowerCase();
125
- if (ci && ci !== "0" && ci !== "false") return false;
125
+ if (env.CI !== undefined && ci !== "0" && ci !== "false") return false;
126
126
  // These variables are untrusted hints, especially when forwarded over SSH.
127
127
  // They may start the capability probe, but only an OSC 1337 reply containing
128
128
  // the File capability can make the transport available.
129
- return env.LC_TERMINAL === "iTerm2" || env.TERM_PROGRAM === "iTerm.app";
129
+ const lcTerminal = env.LC_TERMINAL?.trim().toLowerCase();
130
+ const termProgram = env.TERM_PROGRAM?.trim().toLowerCase();
131
+ return lcTerminal === "iterm2" || termProgram === "iterm.app" || termProgram === "iterm2";
130
132
  }
131
133
  export function createNativePetTransport(o: {
132
134
  ui: NativePetUi;
@@ -129,10 +129,17 @@ export class MCPAddWizard extends Container {
129
129
  clientId: string,
130
130
  clientSecret: string,
131
131
  scopes: string,
132
+ signal: AbortSignal,
132
133
  ) => Promise<MCPAddWizardOAuthResult>)
133
134
  | null = null;
134
135
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
135
136
  #onRenderCallback: (() => void) | null = null;
137
+ #onCommandPaletteCallback: ((keyData: string) => boolean) | null = null;
138
+ #onOAuthCredentialCleanupCallback: ((credentialId: string) => Promise<void> | void) | null = null;
139
+ #onOAuthCredentialCleanupErrorCallback: ((credentialId: string, error: unknown) => void) | null = null;
140
+ #oauthAbortController: AbortController | undefined;
141
+ #oauthCredentialCleanupStarted = false;
142
+ #completed = false;
136
143
  #disposed = false;
137
144
  #transitionTimers = new Set<NodeJS.Timeout>();
138
145
  #healthCheckSpinner?: NodeJS.Timeout;
@@ -149,10 +156,14 @@ export class MCPAddWizard extends Container {
149
156
  clientId: string,
150
157
  clientSecret: string,
151
158
  scopes: string,
159
+ signal: AbortSignal,
152
160
  ) => Promise<MCPAddWizardOAuthResult>,
153
161
  onTestConnection?: (config: MCPServerConfig) => Promise<void>,
154
162
  onRender?: () => void,
163
+ onCommandPalette?: (keyData: string) => boolean,
155
164
  initialName?: string,
165
+ onOAuthCredentialCleanup?: (credentialId: string) => Promise<void> | void,
166
+ onOAuthCredentialCleanupError?: (credentialId: string, error: unknown) => void,
156
167
  ) {
157
168
  super();
158
169
  this.#onCompleteCallback = onComplete;
@@ -160,6 +171,9 @@ export class MCPAddWizard extends Container {
160
171
  this.#onOAuthCallback = onOAuth ?? null;
161
172
  this.#onTestConnectionCallback = onTestConnection ?? null;
162
173
  this.#onRenderCallback = onRender ?? null;
174
+ this.#onCommandPaletteCallback = onCommandPalette ?? null;
175
+ this.#onOAuthCredentialCleanupCallback = onOAuthCredentialCleanup ?? null;
176
+ this.#onOAuthCredentialCleanupErrorCallback = onOAuthCredentialCleanupError ?? null;
163
177
  if (initialName && initialName.trim().length > 0) {
164
178
  this.#state.name = initialName.trim();
165
179
  this.#currentStep = "transport";
@@ -190,6 +204,15 @@ export class MCPAddWizard extends Container {
190
204
  if (this.#disposed) return;
191
205
  this.#disposed = true;
192
206
  this.#asyncGeneration += 1;
207
+ this.#oauthAbortController?.abort(new Error("OAuth flow cancelled"));
208
+ this.#oauthAbortController = undefined;
209
+ if (!this.#completed && !this.#oauthCredentialCleanupStarted && this.#state.oauthCredentialId) {
210
+ this.#oauthCredentialCleanupStarted = true;
211
+ const credentialId = this.#state.oauthCredentialId;
212
+ void Promise.resolve(this.#onOAuthCredentialCleanupCallback?.(credentialId)).catch(error => {
213
+ this.#onOAuthCredentialCleanupErrorCallback?.(credentialId, error);
214
+ });
215
+ }
193
216
  for (const timer of this.#transitionTimers) {
194
217
  clearTimeout(timer);
195
218
  }
@@ -518,6 +541,10 @@ export class MCPAddWizard extends Container {
518
541
 
519
542
  // Handle Escape (always handled by wizard)
520
543
  if (matchesAppInterrupt(keyData)) {
544
+ if (this.#oauthAbortController) {
545
+ this.#onCancelCallback();
546
+ return;
547
+ }
521
548
  if (this.#currentStep === "name") {
522
549
  // Cancel wizard
523
550
  this.#onCancelCallback();
@@ -528,6 +555,8 @@ export class MCPAddWizard extends Container {
528
555
  return;
529
556
  }
530
557
 
558
+ if (this.#onCommandPaletteCallback?.(keyData) === true) return;
559
+
531
560
  // If we have an input field, let it handle the input
532
561
  if (this.#inputField) {
533
562
  // Handle Enter to proceed
@@ -635,6 +664,7 @@ export class MCPAddWizard extends Container {
635
664
  break;
636
665
  case "oauth-scopes":
637
666
  this.#state.oauthScopes = value; // Optional
667
+ this.#inputField = null;
638
668
  // Launch OAuth flow
639
669
  void this.#launchOAuthFlow();
640
670
  return;
@@ -1153,7 +1183,7 @@ export class MCPAddWizard extends Container {
1153
1183
  }
1154
1184
 
1155
1185
  async #launchOAuthFlow(): Promise<void> {
1156
- if (this.#disposed) return;
1186
+ if (this.#disposed || this.#oauthAbortController) return;
1157
1187
  const generation = ++this.#asyncGeneration;
1158
1188
  if (!this.#onOAuthCallback) {
1159
1189
  this.#contentContainer.clear();
@@ -1189,6 +1219,8 @@ export class MCPAddWizard extends Container {
1189
1219
  this.#contentContainer.addChild(new Text(theme.fg("muted", "(Press Esc to cancel)"), 0, 0));
1190
1220
  this.#requestRender();
1191
1221
 
1222
+ const oauthAbortController = new AbortController();
1223
+ this.#oauthAbortController = oauthAbortController;
1192
1224
  try {
1193
1225
  // Call OAuth handler
1194
1226
  const oauthResult = await this.#onOAuthCallback(
@@ -1198,6 +1230,7 @@ export class MCPAddWizard extends Container {
1198
1230
  this.#state.oauthClientId,
1199
1231
  this.#state.oauthClientSecret,
1200
1232
  this.#state.oauthScopes,
1233
+ oauthAbortController.signal,
1201
1234
  );
1202
1235
  if (this.#disposed || generation !== this.#asyncGeneration) return;
1203
1236
 
@@ -1276,6 +1309,7 @@ export class MCPAddWizard extends Container {
1276
1309
  healthPassed ? 1000 : 2000,
1277
1310
  );
1278
1311
  } catch (error) {
1312
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
1279
1313
  // Show error with options to retry or go back
1280
1314
  const errorMsg = sanitize(error instanceof Error ? error.message : String(error));
1281
1315
  this.#contentContainer.clear();
@@ -1311,11 +1345,14 @@ export class MCPAddWizard extends Container {
1311
1345
  // Set up as a selector step
1312
1346
  this.#selectedIndex = 0;
1313
1347
  this.#currentStep = "oauth-error";
1348
+ } finally {
1349
+ if (this.#oauthAbortController === oauthAbortController) this.#oauthAbortController = undefined;
1314
1350
  }
1315
1351
  }
1316
1352
 
1317
1353
  #complete(): void {
1318
1354
  if (!this.#state.scope) return;
1355
+ this.#completed = true;
1319
1356
 
1320
1357
  // Build the config
1321
1358
  const config: MCPServerConfig = this.#buildConfig();
@@ -1009,6 +1009,7 @@ export class SettingsSelectorComponent extends Container {
1009
1009
  options = [
1010
1010
  { value: "en", label: uiString(language, "settings.language.english") },
1011
1011
  { value: "ko", label: uiString(language, "settings.language.korean") },
1012
+ { value: "ja", label: uiString(language, "settings.language.japanese") },
1012
1013
  ];
1013
1014
  }
1014
1015
  if (def.path === "statusLine.preset") {
@@ -709,6 +709,27 @@ export class StatusLineComponent implements Component {
709
709
  return partsWidth + sepTotal + 2 + capWidth;
710
710
  }
711
711
 
712
+ /**
713
+ * Render the overflow cue for evicted status segments.
714
+ *
715
+ * Deliberately bypasses `#renderStatusGroup`: that primitive spends two
716
+ * padding cells plus any end cap before content (see `#groupWidth`), so it
717
+ * cannot produce marker-only output at one or two columns. Emits the bare
718
+ * marker with no padding and no caps so the final `truncateToWidth` in
719
+ * `render()` stays a defensive guard rather than the thing that decides
720
+ * whether the cue survives.
721
+ *
722
+ * `…+N` needs `2 + digits(N)` cells. When that does not fit, the count is
723
+ * intentionally omitted rather than truncated into a wrong number, so the
724
+ * cue is either exact or countless — never misleading.
725
+ */
726
+ #renderOverflowMarker(count: number, available: number): string {
727
+ if (available <= 0 || count <= 0) return "";
728
+ const withCount = `…+${count}`;
729
+ const text = visibleWidth(withCount) <= available ? withCount : "…";
730
+ return theme.fg("dim", text);
731
+ }
732
+
712
733
  #renderStatusGroup(
713
734
  parts: string[],
714
735
  direction: "left" | "right",
@@ -855,54 +876,84 @@ export class StatusLineComponent implements Component {
855
876
  const seg = precollected ?? this.#collectStatusSegments(width, this.#resolveSettings());
856
877
  const { ctx, separatorDef, bgAnsi, fgAnsi, sepAnsi, previewHighlightSegment } = seg;
857
878
  const { leftSepWidth, rightSepWidth, leftCapWidth, rightCapWidth } = seg;
858
- const left = [...seg.left];
859
- const leftIds = [...seg.leftSegIds];
860
- const right = [...seg.right];
861
879
  const topFillWidth = Math.max(0, width);
862
880
 
863
- let leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
864
- let rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
865
- const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
866
-
867
- if (topFillWidth > 0) {
868
- // Shrink path before dropping right-side telemetry — path is the only elastic segment,
869
- // and presets such as default-usage should not hide usage just because cwd is long.
870
- const pathIdx = leftIds.indexOf("path");
871
- if (pathIdx >= 0 && totalWidth() > topFillWidth) {
872
- const overflow = totalWidth() - topFillWidth;
873
- const shrunk = this.#shrinkPathToWidth(left[pathIdx], ctx, overflow);
874
- if (shrunk !== null) {
875
- left[pathIdx] = previewHighlightSegment === "path" ? `\x1b[7m${shrunk}\x1b[27m` : shrunk;
881
+ /**
882
+ * Evict against `budget` using the existing configured-order semantics
883
+ * (shrink path, then pop right, then pop left) and report how many
884
+ * segments were dropped. Runs from the original segment lists each time so
885
+ * the marker reservation can be recomputed without compounding evictions.
886
+ */
887
+ const layout = (budget: number) => {
888
+ const left = [...seg.left];
889
+ const leftIds = [...seg.leftSegIds];
890
+ const right = [...seg.right];
891
+ let leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
892
+ let rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
893
+ const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
894
+ let dropped = 0;
895
+
896
+ if (topFillWidth > 0) {
897
+ // Shrink path before dropping right-side telemetry — path is the only elastic segment,
898
+ // and presets such as default-usage should not hide usage just because cwd is long.
899
+ const pathIdx = leftIds.indexOf("path");
900
+ if (pathIdx >= 0 && totalWidth() > budget) {
901
+ const overflow = totalWidth() - budget;
902
+ const shrunk = this.#shrinkPathToWidth(left[pathIdx], ctx, overflow);
903
+ if (shrunk !== null) {
904
+ left[pathIdx] = previewHighlightSegment === "path" ? `\x1b[7m${shrunk}\x1b[27m` : shrunk;
905
+ leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
906
+ }
907
+ }
908
+ while (totalWidth() > budget && right.length > 0) {
909
+ right.pop();
910
+ dropped += 1;
911
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
912
+ }
913
+ while (totalWidth() > budget && left.length > 0) {
914
+ left.pop();
915
+ leftIds.pop();
916
+ dropped += 1;
876
917
  leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
877
918
  }
878
919
  }
879
- while (totalWidth() > topFillWidth && right.length > 0) {
880
- right.pop();
881
- rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
882
- }
883
- while (totalWidth() > topFillWidth && left.length > 0) {
884
- left.pop();
885
- leftIds.pop();
886
- leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
920
+ return { left, right, leftWidth, rightWidth, dropped };
921
+ };
922
+
923
+ // First pass with the full budget establishes whether anything is lost at
924
+ // all. Only after loss is detected do we reserve marker cells, so a rail
925
+ // that already fits is byte-identical to before.
926
+ let placed = layout(topFillWidth);
927
+ let reserved = 0;
928
+ if (placed.dropped > 0 && topFillWidth > 0) {
929
+ // Re-evict against the reduced budget so the cue never overflows the
930
+ // rail. Reserving cells can push out another segment, which can in turn
931
+ // widen the count (9 -> 10 gains a digit), so converge instead of
932
+ // reserving once: otherwise the exact count would degrade to a bare
933
+ // marker even when the rail had room for it.
934
+ for (let pass = 0; pass < 4; pass += 1) {
935
+ const needed = Math.min(topFillWidth, visibleWidth(`…+${placed.dropped}`));
936
+ if (needed <= reserved) break;
937
+ reserved = needed;
938
+ placed = layout(Math.max(0, topFillWidth - reserved));
887
939
  }
888
940
  }
941
+ const marker = this.#renderOverflowMarker(placed.dropped, reserved);
889
942
 
890
- const leftGroup = this.#renderStatusGroup(left, "left", separatorDef, bgAnsi, fgAnsi, sepAnsi);
891
- const rightGroup = this.#renderStatusGroup(right, "right", separatorDef, bgAnsi, fgAnsi, sepAnsi);
892
- if (!leftGroup && !rightGroup) return "";
943
+ const leftGroup = this.#renderStatusGroup(placed.left, "left", separatorDef, bgAnsi, fgAnsi, sepAnsi);
944
+ const rightGroup = this.#renderStatusGroup(placed.right, "right", separatorDef, bgAnsi, fgAnsi, sepAnsi);
945
+ if (!leftGroup && !rightGroup) return marker;
893
946
 
894
- if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
895
- return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
947
+ if (topFillWidth === 0 || placed.left.length === 0 || placed.right.length === 0) {
948
+ return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup + marker;
896
949
  }
897
950
 
898
- leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
899
- rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
900
- const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
951
+ const gapWidth = Math.max(1, topFillWidth - placed.leftWidth - placed.rightWidth - visibleWidth(marker));
901
952
  const sessionName = seg.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
902
953
  const accentHex = sessionName ? getSessionAccentHex(sessionName) : undefined;
903
954
  const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
904
955
  const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
905
- return leftGroup + gapFill + rightGroup;
956
+ return leftGroup + gapFill + rightGroup + marker;
906
957
  }
907
958
 
908
959
  /**
@@ -986,7 +1037,9 @@ export class StatusLineComponent implements Component {
986
1037
 
987
1038
  const packedRows: string[][] = [];
988
1039
  let current: string[] = [];
989
- for (const item of items) {
1040
+ let dropped = 0;
1041
+ for (let index = 0; index < items.length; index += 1) {
1042
+ const item = items[index];
990
1043
  if (current.length === 0) {
991
1044
  current.push(item.content);
992
1045
  continue;
@@ -997,16 +1050,67 @@ export class StatusLineComponent implements Component {
997
1050
  } else {
998
1051
  packedRows.push(current);
999
1052
  current = [item.content];
1000
- if (packedRows.length >= maxRows) break;
1053
+ if (packedRows.length >= maxRows) {
1054
+ // `current` holds the item that opened the row we cannot admit,
1055
+ // and everything after it is never visited: all of it is lost.
1056
+ dropped = items.length - index;
1057
+ current = [];
1058
+ break;
1059
+ }
1001
1060
  }
1002
1061
  }
1003
- if (packedRows.length < maxRows && current.length > 0) {
1004
- packedRows.push(current);
1062
+ if (current.length > 0) {
1063
+ if (packedRows.length < maxRows) packedRows.push(current);
1064
+ else dropped += current.length;
1065
+ }
1066
+
1067
+ // Prune every row down to what actually fits. Without this, an oversized
1068
+ // row survives here and the defensive truncation in `render()` trims it
1069
+ // into a lone ellipsis, which reads exactly like the overflow cue on a row
1070
+ // that is not supposed to carry one. Emptied rows are removed rather than
1071
+ // emitted blank, so at tiny widths the marker ends up alone on one row.
1072
+ for (let index = packedRows.length - 1; index >= 0; index -= 1) {
1073
+ const row = packedRows[index];
1074
+ while (row.length > 0 && this.#groupWidth(row, seg.leftCapWidth, seg.leftSepWidth) > topFillWidth) {
1075
+ row.pop();
1076
+ dropped += 1;
1077
+ }
1078
+ if (row.length === 0) packedRows.splice(index, 1);
1005
1079
  }
1006
1080
 
1007
- rows = packedRows.map(row =>
1008
- this.#renderStatusGroup(row, "left", seg.separatorDef, seg.bgAnsi, seg.fgAnsi, seg.sepAnsi),
1009
- );
1081
+ // Reserve marker cells on whichever row ends up last, evicting from its
1082
+ // tail until the cue fits. Never opens a new row: that would contradict
1083
+ // maxRows. Emptying that row is fine -- the marker then occupies it alone.
1084
+ // Popping it instead would move the marker onto a row that carries no
1085
+ // reservation, and the defensive truncation in `render()` would recreate
1086
+ // the lookalike-ellipsis failure one row further up.
1087
+ let marker = "";
1088
+ if (dropped > 0 && topFillWidth > 0) {
1089
+ let reserved = Math.min(topFillWidth, visibleWidth(`…+${dropped}`));
1090
+ while (packedRows.length > 0) {
1091
+ const lastRow = packedRows[packedRows.length - 1];
1092
+ if (this.#groupWidth(lastRow, seg.leftCapWidth, seg.leftSepWidth) + reserved <= topFillWidth) break;
1093
+ if (lastRow.length === 0) break;
1094
+ lastRow.pop();
1095
+ dropped += 1;
1096
+ reserved = Math.min(topFillWidth, visibleWidth(`…+${dropped}`));
1097
+ }
1098
+ marker = this.#renderOverflowMarker(dropped, reserved);
1099
+ }
1100
+
1101
+ rows = packedRows.map((row, index) => {
1102
+ const rendered = this.#renderStatusGroup(
1103
+ row,
1104
+ "left",
1105
+ seg.separatorDef,
1106
+ seg.bgAnsi,
1107
+ seg.fgAnsi,
1108
+ seg.sepAnsi,
1109
+ );
1110
+ return index === packedRows.length - 1 ? rendered + marker : rendered;
1111
+ });
1112
+ // Every content row was pruned away, so the cue is the only thing left.
1113
+ if (packedRows.length === 0 && marker) rows = [marker];
1010
1114
  }
1011
1115
 
1012
1116
  this.#renderedRowsCache = { key: cacheKey, rows };
@@ -1058,7 +1162,11 @@ export class StatusLineComponent implements Component {
1058
1162
 
1059
1163
  const statusRows = this.#buildStatusRows(width, this.#resolveMaxRows());
1060
1164
  for (const statusRow of statusRows) {
1061
- if (statusRow) lines.push(truncateToWidth(statusRow, width));
1165
+ if (!statusRow) continue;
1166
+ // Guard on the truncated value: at width 0 the truncation collapses to an
1167
+ // empty string, and emitting that would add a blank row rather than none.
1168
+ const truncated = truncateToWidth(statusRow, width);
1169
+ if (truncated) lines.push(truncated);
1062
1170
  }
1063
1171
 
1064
1172
  const showHooks = this.#settings.showHookStatus ?? true;
@@ -24,6 +24,8 @@ export interface WelcomeComponentOptions {
24
24
  collapseChangelog?: boolean;
25
25
  buildLabel?: string;
26
26
  keyDisplayContext?: KeyDisplayContext;
27
+ /** Render the resting logo frame instead of playing the startup gradient sweep. */
28
+ reducedMotion?: boolean;
27
29
  }
28
30
 
29
31
  const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 9;
@@ -73,6 +75,12 @@ export class WelcomeComponent implements Component {
73
75
  */
74
76
  playIntro(requestRender: () => void): void {
75
77
  this.#stopAnimation();
78
+ if (this.options.reducedMotion) {
79
+ // `#animStart` stays null, which `#currentLogoFrame` already resolves to
80
+ // the resting frame, so one render settles the surface with no timer.
81
+ requestRender();
82
+ return;
83
+ }
76
84
  this.#animStart = performance.now();
77
85
  requestRender();
78
86
  this.#animTimer = setInterval(() => {
@@ -264,12 +264,15 @@ export class GoalModeController {
264
264
  if (this.#enabled || this.#paused) this.ctx.modeGate.enter("goal");
265
265
  if (restored?.goal) {
266
266
  this.#previousTools = this.ctx.session.getActiveToolNames();
267
- await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
267
+ if (!this.ctx.session.isExplicitEmptyToolSelection()) {
268
+ await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
269
+ }
268
270
  }
269
271
  this.#updateStatus();
270
272
  return true;
271
273
  }
272
274
  if (recoveryHydration) return false;
275
+ if (this.ctx.session.isExplicitEmptyToolSelection()) return false;
273
276
  const pendingGoal = goalEnabled
274
277
  ? await consumePendingGoalModeRequest(this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.getSessionId())
275
278
  : null;
@@ -321,7 +324,9 @@ export class GoalModeController {
321
324
  objective: options.objective ?? "",
322
325
  provenance: options.provenance,
323
326
  });
324
- await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
327
+ if (!this.ctx.session.isExplicitEmptyToolSelection()) {
328
+ await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
329
+ }
325
330
  this.ctx.session.setGoalModeState(state);
326
331
  this.#enabled = true;
327
332
  this.#resetContinuationSuppression();