@oh-my-pi/pi-coding-agent 16.3.4 → 16.3.6
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 +36 -0
- package/dist/cli.js +3522 -3485
- package/dist/types/edit/file-snapshot-store.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +0 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/shared-events.d.ts +8 -1
- package/dist/types/hindsight/content.d.ts +4 -0
- package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
- package/dist/types/internal-urls/types.d.ts +10 -0
- package/dist/types/lsp/config.d.ts +4 -0
- package/dist/types/lsp/edits.d.ts +2 -0
- package/dist/types/mcp/oauth-discovery.d.ts +30 -0
- package/dist/types/modes/components/assistant-message.d.ts +15 -10
- package/dist/types/modes/components/bash-execution.d.ts +6 -0
- package/dist/types/modes/components/eval-execution.d.ts +6 -0
- package/dist/types/modes/components/login-dialog.d.ts +10 -2
- package/dist/types/modes/components/tool-execution.d.ts +3 -13
- package/dist/types/modes/components/transcript-container.d.ts +26 -29
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
- package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/subprocess/worker-runtime.d.ts +11 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/bash.d.ts +0 -2
- package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -2
- package/dist/types/tools/renderers.d.ts +0 -20
- package/dist/types/tools/ssh.d.ts +0 -2
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/utils/git.d.ts +16 -0
- package/package.json +12 -12
- package/src/cli/auth-broker-cli.ts +13 -2
- package/src/cli/tiny-models-cli.ts +12 -5
- package/src/edit/file-snapshot-store.ts +5 -2
- package/src/edit/renderer.ts +0 -5
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/shared-events.ts +9 -1
- package/src/hindsight/content.ts +31 -0
- package/src/internal-urls/artifact-protocol.ts +97 -53
- package/src/internal-urls/types.ts +10 -0
- package/src/lsp/config.ts +15 -0
- package/src/lsp/edits.ts +28 -7
- package/src/lsp/index.ts +46 -4
- package/src/mcp/oauth-discovery.ts +88 -18
- package/src/mnemopi/state.ts +26 -2
- package/src/modes/components/assistant-message.ts +134 -82
- package/src/modes/components/bash-execution.ts +9 -0
- package/src/modes/components/chat-transcript-builder.ts +8 -4
- package/src/modes/components/eval-execution.ts +9 -0
- package/src/modes/components/login-dialog.ts +16 -2
- package/src/modes/components/mcp-add-wizard.ts +9 -1
- package/src/modes/components/tool-execution.ts +4 -50
- package/src/modes/components/transcript-container.ts +82 -458
- package/src/modes/components/tree-selector.ts +9 -3
- package/src/modes/controllers/command-controller.ts +0 -3
- package/src/modes/controllers/event-controller.ts +74 -14
- package/src/modes/controllers/extension-ui-controller.ts +0 -1
- package/src/modes/controllers/input-controller.ts +4 -10
- package/src/modes/controllers/mcp-command-controller.ts +106 -29
- package/src/modes/controllers/selector-controller.ts +9 -1
- package/src/modes/interactive-mode.ts +12 -8
- package/src/modes/rpc/rpc-client.ts +8 -4
- package/src/modes/rpc/rpc-mode.ts +1 -0
- package/src/modes/rpc/rpc-types.ts +13 -1
- package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
- package/src/modes/utils/transcript-render-helpers.ts +40 -13
- package/src/modes/utils/ui-helpers.ts +12 -7
- package/src/prompts/tools/read.md +1 -1
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +148 -1
- package/src/session/session-context.ts +7 -0
- package/src/subprocess/worker-runtime.ts +219 -2
- package/src/task/worktree.ts +28 -6
- package/src/tiny/worker.ts +14 -4
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/bash.ts +0 -4
- package/src/tools/browser/tab-supervisor.ts +47 -3
- package/src/tools/eval-render.ts +0 -20
- package/src/tools/grep.ts +19 -2
- package/src/tools/path-utils.ts +4 -0
- package/src/tools/read.ts +261 -21
- package/src/tools/renderers.ts +0 -20
- package/src/tools/ssh.ts +0 -16
- package/src/tools/write.ts +13 -6
- package/src/utils/git.ts +20 -0
- package/src/utils/open.ts +51 -6
|
@@ -18,6 +18,7 @@ import type { SessionTreeNode } from "../../session/session-entries";
|
|
|
18
18
|
import { toPathList } from "../../tools/path-utils";
|
|
19
19
|
import { shortenPath } from "../../tools/render-utils";
|
|
20
20
|
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
21
|
+
import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
|
|
21
22
|
import { DynamicBorder } from "./dynamic-border";
|
|
22
23
|
import { centeredWindow, contentRowWidth, renderScrollableList } from "./selector-helpers";
|
|
23
24
|
|
|
@@ -615,15 +616,20 @@ class TreeList implements Component {
|
|
|
615
616
|
const content = normalize(this.#extractContent(msgWithContent.content));
|
|
616
617
|
result = theme.fg("dim", "developer: ") + theme.fg("muted", content);
|
|
617
618
|
} else if (role === "assistant") {
|
|
619
|
+
const presentation = resolveAssistantErrorPresentation(msg);
|
|
620
|
+
if (presentation.kind === "compact-recovered") {
|
|
621
|
+
result = theme.fg("success", "assistant: ") + theme.fg("dim", presentation.text);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
618
624
|
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
|
|
619
625
|
const textContent = normalize(this.#extractContent(msgWithContent.content));
|
|
620
626
|
if (textContent) {
|
|
621
627
|
result = theme.fg("success", "assistant: ") + textContent;
|
|
628
|
+
} else if (presentation.kind === "full") {
|
|
629
|
+
result =
|
|
630
|
+
theme.fg("success", "assistant: ") + theme.fg("error", normalize(presentation.text).slice(0, 80));
|
|
622
631
|
} else if (msgWithContent.stopReason === "aborted") {
|
|
623
632
|
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
|
|
624
|
-
} else if (msgWithContent.errorMessage) {
|
|
625
|
-
const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
|
|
626
|
-
result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
|
|
627
633
|
} else {
|
|
628
634
|
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
|
|
629
635
|
}
|
|
@@ -1029,9 +1029,6 @@ export class CommandController {
|
|
|
1029
1029
|
return;
|
|
1030
1030
|
}
|
|
1031
1031
|
const name = this.ctx.sessionManager.getSessionName()!;
|
|
1032
|
-
setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd());
|
|
1033
|
-
this.ctx.statusLine.invalidate();
|
|
1034
|
-
this.ctx.updateEditorBorderColor();
|
|
1035
1032
|
this.ctx.showStatus(`Session renamed to "${name}".`);
|
|
1036
1033
|
} catch (err) {
|
|
1037
1034
|
this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -84,6 +84,8 @@ export class EventController {
|
|
|
84
84
|
// restored when the banner clears at the next `agent_start` (see
|
|
85
85
|
// #handleMessageEnd / #handleAgentStart).
|
|
86
86
|
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
|
|
87
|
+
#retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
88
|
+
#retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
|
|
87
89
|
#idleCompactionTimer?: NodeJS.Timeout;
|
|
88
90
|
#idleRecapTimer?: NodeJS.Timeout;
|
|
89
91
|
// In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
|
|
@@ -325,6 +327,42 @@ export class EventController {
|
|
|
325
327
|
this.#terminalProgressActive = false;
|
|
326
328
|
}
|
|
327
329
|
|
|
330
|
+
#trackRetrySupersededAssistantComponent(component: AssistantMessageComponent | undefined): void {
|
|
331
|
+
if (!component) return;
|
|
332
|
+
const persistenceKey = component.messagePersistenceKey();
|
|
333
|
+
if (persistenceKey) this.#retrySupersededAssistantComponents.set(persistenceKey, component);
|
|
334
|
+
if (!this.#retrySupersededAssistantQueue.includes(component)) {
|
|
335
|
+
this.#retrySupersededAssistantQueue.push(component);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#takeRetrySupersededAssistantComponent(persistenceKey: string | undefined): AssistantMessageComponent | undefined {
|
|
340
|
+
if (persistenceKey) {
|
|
341
|
+
const component = this.#retrySupersededAssistantComponents.get(persistenceKey);
|
|
342
|
+
if (component) {
|
|
343
|
+
this.#retrySupersededAssistantComponents.delete(persistenceKey);
|
|
344
|
+
this.#retrySupersededAssistantQueue = this.#retrySupersededAssistantQueue.filter(
|
|
345
|
+
item => item !== component,
|
|
346
|
+
);
|
|
347
|
+
return component;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
while (this.#retrySupersededAssistantQueue.length > 0) {
|
|
351
|
+
const component = this.#retrySupersededAssistantQueue.shift();
|
|
352
|
+
if (!component) continue;
|
|
353
|
+
const key = component.messagePersistenceKey();
|
|
354
|
+
if (key && this.#retrySupersededAssistantComponents.get(key) !== component) continue;
|
|
355
|
+
if (key) this.#retrySupersededAssistantComponents.delete(key);
|
|
356
|
+
return component;
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#clearRetrySupersededAssistantComponents(): void {
|
|
362
|
+
this.#retrySupersededAssistantComponents.clear();
|
|
363
|
+
this.#retrySupersededAssistantQueue = [];
|
|
364
|
+
}
|
|
365
|
+
|
|
328
366
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
329
367
|
this.#lastIntent = undefined;
|
|
330
368
|
this.#readToolCallArgs.clear();
|
|
@@ -468,7 +506,7 @@ export class EventController {
|
|
|
468
506
|
if (!components) return;
|
|
469
507
|
let removed = false;
|
|
470
508
|
for (const component of components) {
|
|
471
|
-
if (!this.ctx.chatContainer.
|
|
509
|
+
if (!this.ctx.chatContainer.isBlockUncommitted(component)) continue;
|
|
472
510
|
this.ctx.chatContainer.removeChild(component);
|
|
473
511
|
removed = true;
|
|
474
512
|
}
|
|
@@ -493,16 +531,20 @@ export class EventController {
|
|
|
493
531
|
* Resolve the pending displaceable poll block before the next block lands.
|
|
494
532
|
* A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
|
|
495
533
|
* is removed so repeated polls read as one persistent poll — while anything
|
|
496
|
-
* else seals it in place as final history. Removal is
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
534
|
+
* else seals it in place as final history. Removal is gated on none of the
|
|
535
|
+
* block's rows having entered native scrollback: rows already on the tape
|
|
536
|
+
* are immutable visual history, so a scrolled-off poll seals instead of
|
|
537
|
+
* being retracted.
|
|
500
538
|
*/
|
|
501
539
|
#resolveDisplaceablePoll(nextToolName?: string): void {
|
|
502
540
|
const previous = this.#displaceablePollComponent;
|
|
503
541
|
if (!previous) return;
|
|
504
542
|
this.#displaceablePollComponent = undefined;
|
|
505
|
-
if (
|
|
543
|
+
if (
|
|
544
|
+
nextToolName === "job" &&
|
|
545
|
+
previous.isDisplaceableBlock() &&
|
|
546
|
+
this.ctx.chatContainer.isBlockUncommitted(previous)
|
|
547
|
+
) {
|
|
506
548
|
this.ctx.chatContainer.removeChild(previous);
|
|
507
549
|
}
|
|
508
550
|
// Sealing stops the waiting-poll spinner and freezes the block (for a
|
|
@@ -520,7 +562,9 @@ export class EventController {
|
|
|
520
562
|
}
|
|
521
563
|
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
522
564
|
this.#displaceableTodoComponent = undefined;
|
|
523
|
-
this.ctx.chatContainer.
|
|
565
|
+
if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
|
|
566
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
567
|
+
}
|
|
524
568
|
previous.seal();
|
|
525
569
|
this.ctx.ui.requestRender();
|
|
526
570
|
return;
|
|
@@ -980,7 +1024,9 @@ export class EventController {
|
|
|
980
1024
|
const previous = this.#displaceableTodoComponent;
|
|
981
1025
|
if (previous && previous !== component && previous.isDisplaceableBlock()) {
|
|
982
1026
|
this.#displaceableTodoComponent = undefined;
|
|
983
|
-
this.ctx.chatContainer.
|
|
1027
|
+
if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
|
|
1028
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
1029
|
+
}
|
|
984
1030
|
previous.seal();
|
|
985
1031
|
}
|
|
986
1032
|
this.#displaceableTodoComponent = component;
|
|
@@ -1219,6 +1265,7 @@ export class EventController {
|
|
|
1219
1265
|
}
|
|
1220
1266
|
|
|
1221
1267
|
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
|
|
1268
|
+
this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
|
|
1222
1269
|
this.#stopWorkingLoader();
|
|
1223
1270
|
this.ctx.statusContainer.clear();
|
|
1224
1271
|
if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
|
|
@@ -1246,7 +1293,21 @@ export class EventController {
|
|
|
1246
1293
|
this.ctx.retryLoader = undefined;
|
|
1247
1294
|
this.ctx.statusContainer.clear();
|
|
1248
1295
|
}
|
|
1249
|
-
if (
|
|
1296
|
+
if (event.success) {
|
|
1297
|
+
let appliedRecovered = false;
|
|
1298
|
+
for (const recovered of event.recoveredErrors ?? []) {
|
|
1299
|
+
const component = this.#takeRetrySupersededAssistantComponent(recovered.persistenceKey);
|
|
1300
|
+
if (!component) continue;
|
|
1301
|
+
component.applyRetryRecovery(recovered.retryRecovery);
|
|
1302
|
+
if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
|
|
1303
|
+
appliedRecovered = true;
|
|
1304
|
+
}
|
|
1305
|
+
if (appliedRecovered || (event.recoveredErrors?.length ?? 0) > 0) {
|
|
1306
|
+
this.ctx.clearPinnedError();
|
|
1307
|
+
}
|
|
1308
|
+
this.#clearRetrySupersededAssistantComponents();
|
|
1309
|
+
} else {
|
|
1310
|
+
this.#clearRetrySupersededAssistantComponents();
|
|
1250
1311
|
this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
|
|
1251
1312
|
}
|
|
1252
1313
|
this.#ensureWorkingLoaderWhileStreaming();
|
|
@@ -1268,15 +1329,14 @@ export class EventController {
|
|
|
1268
1329
|
async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
|
|
1269
1330
|
// Consecutive notifications (e.g. per-tool matches from one assistant
|
|
1270
1331
|
// message) merge into the previous block instead of stacking. Mutating an
|
|
1271
|
-
// existing block is only safe while
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
// live block), so the grown block still repaints.
|
|
1332
|
+
// existing block is only safe while none of its rows have entered native
|
|
1333
|
+
// scrollback — committed rows are immutable visual history and a grown
|
|
1334
|
+
// block would shift them.
|
|
1275
1335
|
const previous = this.#lastTtsrNotification;
|
|
1276
1336
|
if (
|
|
1277
1337
|
previous &&
|
|
1278
1338
|
this.ctx.chatContainer.children.at(-1) === previous &&
|
|
1279
|
-
this.ctx.chatContainer.
|
|
1339
|
+
this.ctx.chatContainer.isBlockUncommitted(previous)
|
|
1280
1340
|
) {
|
|
1281
1341
|
previous.addRules(event.rules);
|
|
1282
1342
|
this.ctx.ui.requestRender();
|
|
@@ -880,7 +880,6 @@ export class ExtensionUiController {
|
|
|
880
880
|
|
|
881
881
|
async #updateSessionName(name: string): Promise<void> {
|
|
882
882
|
await this.ctx.sessionManager.setSessionName(name, "user");
|
|
883
|
-
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
|
884
883
|
}
|
|
885
884
|
|
|
886
885
|
#sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
|
|
@@ -33,7 +33,7 @@ import { EnhancedPasteController } from "../../utils/enhanced-paste";
|
|
|
33
33
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
34
34
|
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
|
|
35
35
|
import { resizeImage } from "../../utils/image-resize";
|
|
36
|
-
import { generateSessionTitle
|
|
36
|
+
import { generateSessionTitle } from "../../utils/title-generator";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Slash commands that may carry secrets in their arguments should never be
|
|
@@ -843,16 +843,10 @@ export class InputController {
|
|
|
843
843
|
)
|
|
844
844
|
.then(async title => {
|
|
845
845
|
// Re-check: a concurrent attempt for an earlier message may have
|
|
846
|
-
// already named the session. Don't clobber it.
|
|
846
|
+
// already named the session. Don't clobber it. Terminal title and
|
|
847
|
+
// accent updates fire from the onSessionNameChanged listener.
|
|
847
848
|
if (title && !this.ctx.sessionManager.getSessionName()) {
|
|
848
|
-
|
|
849
|
-
if (applied) {
|
|
850
|
-
setSessionTerminalTitle(
|
|
851
|
-
this.ctx.sessionManager.getSessionName()!,
|
|
852
|
-
this.ctx.sessionManager.getCwd(),
|
|
853
|
-
);
|
|
854
|
-
this.ctx.updateEditorBorderColor();
|
|
855
|
-
}
|
|
849
|
+
await this.ctx.sessionManager.setSessionName(title, "auto");
|
|
856
850
|
}
|
|
857
851
|
})
|
|
858
852
|
.catch(err => {
|
|
@@ -8,7 +8,13 @@ import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
|
8
8
|
import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
9
9
|
import type { SourceMeta } from "../../capability/types";
|
|
10
10
|
import { expandEnvVarsDeep } from "../../discovery/helpers";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
analyzeAuthError,
|
|
13
|
+
discoverOAuthEndpoints,
|
|
14
|
+
fetchResourceMetadataScopes,
|
|
15
|
+
loadAllMCPConfigs,
|
|
16
|
+
MCPManager,
|
|
17
|
+
} from "../../mcp";
|
|
12
18
|
import { connectToServer, disconnectServer, listTools } from "../../mcp/client";
|
|
13
19
|
import {
|
|
14
20
|
addMCPServer,
|
|
@@ -43,6 +49,7 @@ import {
|
|
|
43
49
|
import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
|
|
44
50
|
import { shortenPath } from "../../tools/render-utils";
|
|
45
51
|
import { urlHyperlinkAlways } from "../../tui";
|
|
52
|
+
import { copyToClipboard } from "../../utils/clipboard";
|
|
46
53
|
import { openPath } from "../../utils/open";
|
|
47
54
|
import { ChatBlock } from "../components/chat-block";
|
|
48
55
|
import { MCPAddWizard } from "../components/mcp-add-wizard";
|
|
@@ -73,23 +80,77 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
|
|
|
73
80
|
});
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Minimum column budget for URL wrapping. Below this the terminal is
|
|
85
|
+
* effectively unusable, but we still emit chunks so no character is silently
|
|
86
|
+
* dropped and the user can widen and reflow.
|
|
87
|
+
*/
|
|
88
|
+
const MCP_AUTH_MIN_WRAP_WIDTH = 16;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wrap `url` into rows that each fit inside `width`, prefixed by a shared
|
|
92
|
+
* single-column indent so nested composition doesn't touch column 0. When the
|
|
93
|
+
* label + URL fit on one line, returns a single row; otherwise puts the label
|
|
94
|
+
* on its own row and slices the URL into fixed-width chunks. URL chunks are
|
|
95
|
+
* plain code points — browsers strip whitespace when pasted into the address
|
|
96
|
+
* bar, so a multi-row selection copies back to the intact URL.
|
|
97
|
+
*/
|
|
98
|
+
function wrapUrlRows(label: string, url: string, width: number): string[] {
|
|
99
|
+
const indent = " ";
|
|
100
|
+
const sanitized = replaceTabs(url);
|
|
101
|
+
const effective = Math.max(MCP_AUTH_MIN_WRAP_WIDTH, Math.trunc(width));
|
|
102
|
+
const inlineWidth = indent.length + label.length + 1 + sanitized.length;
|
|
103
|
+
if (inlineWidth <= effective) {
|
|
104
|
+
return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
|
|
105
|
+
}
|
|
106
|
+
const chunkWidth = Math.max(1, effective - indent.length);
|
|
107
|
+
const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
|
|
108
|
+
for (let i = 0; i < sanitized.length; i += chunkWidth) {
|
|
109
|
+
rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
|
|
110
|
+
}
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Renders the MCP OAuth fallback URL. Always shows the full authorization URL
|
|
116
|
+
* as the primary `Copy URL:` target — that works from any machine, including
|
|
117
|
+
* SSH/WSL/headless sessions where the OMP-hosted `/launch` loopback URL would
|
|
118
|
+
* resolve against the user's local browser and fail.
|
|
119
|
+
*
|
|
120
|
+
* The render is `width`-aware: on any viewport narrower than the composed row
|
|
121
|
+
* ({@link TUI#prepareLine} truncates anything wider with `Ellipsis.Omit`, no
|
|
122
|
+
* marker), the URL is hard-wrapped into width-fitted rows so the primary copy
|
|
123
|
+
* target can never silently lose trailing OAuth parameters — the failure mode
|
|
124
|
+
* that motivated #4418 in the first place. Browsers strip whitespace when a
|
|
125
|
+
* multi-row selection is pasted into the address bar, so the reassembled URL
|
|
126
|
+
* is byte-identical to what we rendered.
|
|
127
|
+
*
|
|
128
|
+
* When the flow's callback server hosts a short `launchUrl`, it is offered
|
|
129
|
+
* as an additional local shortcut for wide-terminal local users. The OSC 8
|
|
130
|
+
* hyperlink continues to carry the full URL for terminals that support it.
|
|
131
|
+
*/
|
|
77
132
|
export class MCPAuthorizationLinkPrompt implements Component {
|
|
78
|
-
readonly #
|
|
133
|
+
readonly #fullUrl: string;
|
|
134
|
+
readonly #launchUrl: string | undefined;
|
|
79
135
|
|
|
80
|
-
constructor(url: string) {
|
|
81
|
-
this.#
|
|
136
|
+
constructor(url: string, launchUrl?: string) {
|
|
137
|
+
this.#fullUrl = url;
|
|
138
|
+
this.#launchUrl = launchUrl && launchUrl !== url ? launchUrl : undefined;
|
|
82
139
|
}
|
|
83
140
|
|
|
84
141
|
invalidate(): void {}
|
|
85
142
|
|
|
86
|
-
render(
|
|
87
|
-
const link = urlHyperlinkAlways(this.#
|
|
88
|
-
|
|
143
|
+
render(width: number): readonly string[] {
|
|
144
|
+
const link = urlHyperlinkAlways(this.#fullUrl, "Click here to authorize");
|
|
145
|
+
const lines: string[] = [
|
|
89
146
|
` ${theme.fg("success", "Open authorization URL:")}`,
|
|
90
147
|
` ${theme.fg("accent", link)}`,
|
|
91
|
-
|
|
148
|
+
...wrapUrlRows("Copy URL:", this.#fullUrl, width),
|
|
92
149
|
];
|
|
150
|
+
if (this.#launchUrl) {
|
|
151
|
+
lines.push(...wrapUrlRows("Local shortcut (this machine only):", this.#launchUrl, width));
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
93
154
|
}
|
|
94
155
|
}
|
|
95
156
|
|
|
@@ -506,11 +567,18 @@ export class MCPCommandController {
|
|
|
506
567
|
finalConfig.url,
|
|
507
568
|
authResult.authServerUrl,
|
|
508
569
|
authResult.resourceMetadataUrl,
|
|
570
|
+
{ protectedScopes: authResult.scopes },
|
|
509
571
|
);
|
|
510
572
|
} catch {
|
|
511
573
|
// Ignore discovery error and handle below.
|
|
512
574
|
}
|
|
513
575
|
}
|
|
576
|
+
if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
|
|
577
|
+
// JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
|
|
578
|
+
// advertised protected-resource metadata for the required scopes.
|
|
579
|
+
const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
|
|
580
|
+
if (scopes) oauth = { ...oauth, scopes };
|
|
581
|
+
}
|
|
514
582
|
|
|
515
583
|
if (!oauth) {
|
|
516
584
|
this.ctx.showError(
|
|
@@ -689,7 +757,7 @@ export class MCPCommandController {
|
|
|
689
757
|
stripSameOriginResource: opts?.stripSameOriginResource,
|
|
690
758
|
},
|
|
691
759
|
{
|
|
692
|
-
onAuth: (info: { url: string; instructions?: string }) => {
|
|
760
|
+
onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
|
|
693
761
|
// Show auth URL prominently in chat as one block
|
|
694
762
|
const block = new TranscriptBlock();
|
|
695
763
|
this.ctx.present(block);
|
|
@@ -707,24 +775,25 @@ export class MCPCommandController {
|
|
|
707
775
|
block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
|
|
708
776
|
block.addChild(new Spacer(1));
|
|
709
777
|
block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
778
|
+
// `openPath` is best-effort — it logs spawn failures but never
|
|
779
|
+
// throws, so we always render the copy-URL fallback beneath the
|
|
780
|
+
// "attempting to open browser" line and no earlier try/catch is
|
|
781
|
+
// worth keeping.
|
|
782
|
+
openPath(info.url);
|
|
783
|
+
// Stage the FULL authorization URL on the clipboard via OSC 52.
|
|
784
|
+
// The full URL works from any machine (unlike `launchUrl`, which
|
|
785
|
+
// only resolves against the OMP host), and OSC 52 is a
|
|
786
|
+
// wire-level protocol — the terminal writes it to the user's
|
|
787
|
+
// LOCAL clipboard even when OMP is on a remote SSH box.
|
|
788
|
+
// Best-effort: falls back to the visible copy-URL rows below
|
|
789
|
+
// whether or not the terminal honors OSC 52.
|
|
790
|
+
void copyToClipboard(info.url).catch(() => {});
|
|
791
|
+
block.addChild(new Spacer(1));
|
|
792
|
+
block.addChild(new Text(theme.fg("success", "→ Attempting to open browser..."), 1, 0));
|
|
793
|
+
block.addChild(new Spacer(1));
|
|
794
|
+
block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
|
|
795
|
+
block.addChild(new MCPAuthorizationLinkPrompt(info.url, info.launchUrl));
|
|
796
|
+
this.ctx.ui.requestRender();
|
|
728
797
|
},
|
|
729
798
|
onProgress: (message: string) => {
|
|
730
799
|
this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
|
|
@@ -1010,7 +1079,15 @@ export class MCPCommandController {
|
|
|
1010
1079
|
let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
|
|
1011
1080
|
|
|
1012
1081
|
if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
|
|
1013
|
-
oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl
|
|
1082
|
+
oauth = await discoverOAuthEndpoints(config.url, authResult.authServerUrl, authResult.resourceMetadataUrl, {
|
|
1083
|
+
protectedScopes: authResult.scopes,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
if (oauth && !oauth.scopes && authResult.resourceMetadataUrl) {
|
|
1087
|
+
// JSON-error-body path skips `discoverOAuthEndpoints`; fetch the
|
|
1088
|
+
// advertised protected-resource metadata for the required scopes.
|
|
1089
|
+
const scopes = await fetchResourceMetadataScopes(authResult.resourceMetadataUrl);
|
|
1090
|
+
if (scopes) oauth = { ...oauth, scopes };
|
|
1014
1091
|
}
|
|
1015
1092
|
|
|
1016
1093
|
if (!oauth) {
|
|
@@ -1117,11 +1117,19 @@ export class SelectorController {
|
|
|
1117
1117
|
const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
|
|
1118
1118
|
try {
|
|
1119
1119
|
await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
|
|
1120
|
-
onAuth: (info: { url: string; instructions?: string }) => {
|
|
1120
|
+
onAuth: (info: { url: string; launchUrl?: string; instructions?: string }) => {
|
|
1121
1121
|
const block = new TranscriptBlock();
|
|
1122
|
+
// Full URL first: works from any machine, including SSH boxes
|
|
1123
|
+
// where the OMP-hosted `launchUrl` would resolve against the
|
|
1124
|
+
// user's local browser and fail.
|
|
1122
1125
|
block.addChild(new Text(theme.fg("dim", info.url), 1, 0));
|
|
1123
1126
|
const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
|
|
1124
1127
|
block.addChild(new Text(theme.fg("accent", hyperlink), 1, 0));
|
|
1128
|
+
if (info.launchUrl && info.launchUrl !== info.url) {
|
|
1129
|
+
block.addChild(
|
|
1130
|
+
new Text(theme.fg("dim", `Local shortcut (this machine only): ${info.launchUrl}`), 1, 0),
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1125
1133
|
if (info.instructions) {
|
|
1126
1134
|
block.addChild(new Spacer(1));
|
|
1127
1135
|
block.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
|
|
@@ -930,6 +930,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
930
930
|
pushTerminalTitle();
|
|
931
931
|
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
932
932
|
this.updateEditorBorderColor();
|
|
933
|
+
// Single side-effect point for title changes: every setSessionName caller
|
|
934
|
+
// (first-input titling, /rename, extension renames, plan seeding, replan
|
|
935
|
+
// refresh) gets the terminal title + accent updates from here. Registered
|
|
936
|
+
// before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
|
|
937
|
+
// all of which can reach setSessionName during init.
|
|
938
|
+
this.#eventBusUnsubscribers.push(
|
|
939
|
+
this.sessionManager.onSessionNameChanged(() => {
|
|
940
|
+
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
941
|
+
this.#handleSessionAccentInputsChanged();
|
|
942
|
+
}),
|
|
943
|
+
);
|
|
933
944
|
this.#syncEditorMaxHeight();
|
|
934
945
|
this.isInitialized = true;
|
|
935
946
|
this.ui.requestRender(true);
|
|
@@ -986,9 +997,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
986
997
|
this.session.subscribe(event => {
|
|
987
998
|
void this.#handleGoalSessionEvent(event);
|
|
988
999
|
}),
|
|
989
|
-
this.sessionManager.onSessionNameChanged(() => {
|
|
990
|
-
this.#handleSessionAccentInputsChanged();
|
|
991
|
-
}),
|
|
992
1000
|
onStatusLineSessionAccentChanged(() => {
|
|
993
1001
|
this.#syncStatusLineSettings();
|
|
994
1002
|
this.#handleSessionAccentInputsChanged();
|
|
@@ -2658,11 +2666,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2658
2666
|
// when the user has already chosen a name (preserveContext paths).
|
|
2659
2667
|
const seededName = humanizePlanTitle(options.title);
|
|
2660
2668
|
if (seededName && !this.sessionManager.getSessionName()) {
|
|
2661
|
-
|
|
2662
|
-
if (applied) {
|
|
2663
|
-
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
2664
|
-
this.updateEditorBorderColor();
|
|
2665
|
-
}
|
|
2669
|
+
await this.sessionManager.setSessionName(seededName, "auto");
|
|
2666
2670
|
}
|
|
2667
2671
|
|
|
2668
2672
|
// markPlanReferenceSent fires only on the dispatch path so the synthetic
|
|
@@ -724,17 +724,21 @@ export class RpcClient {
|
|
|
724
724
|
* The server will emit an `open_url` extension_ui_request for the auth URL.
|
|
725
725
|
* Resolves when login completes or rejects on failure.
|
|
726
726
|
*
|
|
727
|
-
* @param onOpenUrl Called when the server emits the auth URL. The host must
|
|
728
|
-
*
|
|
727
|
+
* @param onOpenUrl Called when the server emits the auth URL. The host must
|
|
728
|
+
* open `url` in a browser for the callback-server OAuth flow to complete.
|
|
729
|
+
* When the flow's callback server hosts a `/launch` redirect, `launchUrl`
|
|
730
|
+
* is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
|
|
731
|
+
* the truncation-safe copy target so terminal viewport clipping cannot
|
|
732
|
+
* corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
|
|
729
733
|
*/
|
|
730
734
|
async login(
|
|
731
735
|
providerId: string,
|
|
732
|
-
options?: { onOpenUrl?: (url: string, instructions?: string) => void },
|
|
736
|
+
options?: { onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void },
|
|
733
737
|
): Promise<{ providerId: string }> {
|
|
734
738
|
const { onOpenUrl } = options ?? {};
|
|
735
739
|
const listener = onOpenUrl
|
|
736
740
|
? (req: RpcExtensionUIRequest) => {
|
|
737
|
-
if (req.method === "open_url") onOpenUrl(req.url, req.instructions);
|
|
741
|
+
if (req.method === "open_url") onOpenUrl(req.url, req.instructions, req.launchUrl);
|
|
738
742
|
}
|
|
739
743
|
: undefined;
|
|
740
744
|
if (listener) this.#extensionUiListeners.add(listener);
|
|
@@ -370,7 +370,19 @@ export type RpcExtensionUIRequest =
|
|
|
370
370
|
}
|
|
371
371
|
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
|
|
372
372
|
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }
|
|
373
|
-
| {
|
|
373
|
+
| {
|
|
374
|
+
type: "extension_ui_request";
|
|
375
|
+
id: string;
|
|
376
|
+
method: "open_url";
|
|
377
|
+
url: string;
|
|
378
|
+
/**
|
|
379
|
+
* Short loopback URL that 302-redirects to {@link url}. When present,
|
|
380
|
+
* hosts SHOULD surface it as the copy target so terminal viewport
|
|
381
|
+
* truncation cannot corrupt OAuth query parameters on the full URL.
|
|
382
|
+
*/
|
|
383
|
+
launchUrl?: string;
|
|
384
|
+
instructions?: string;
|
|
385
|
+
};
|
|
374
386
|
|
|
375
387
|
// ============================================================================
|
|
376
388
|
// Host Tool Frames (bidirectional)
|
|
@@ -80,6 +80,7 @@ export class SignInTab implements SetupTab {
|
|
|
80
80
|
#selector: OAuthSelectorComponent;
|
|
81
81
|
#statusLines: string[] = [];
|
|
82
82
|
#authUrl: string | undefined;
|
|
83
|
+
#authLaunchUrl: string | undefined;
|
|
83
84
|
#prompt: PromptState | undefined;
|
|
84
85
|
#promptResolve: ((value: string) => void) | undefined;
|
|
85
86
|
#loginAbort: AbortController | undefined;
|
|
@@ -146,6 +147,9 @@ export class SignInTab implements SetupTab {
|
|
|
146
147
|
theme.fg("accent", `Browser login: ${loginUrlLink(this.#authUrl)} ${loginCopyHint()}`),
|
|
147
148
|
...urlLines.slice(0, 2),
|
|
148
149
|
);
|
|
150
|
+
if (this.#authLaunchUrl) {
|
|
151
|
+
lines.push(theme.fg("dim", `Local shortcut (this machine only): ${this.#authLaunchUrl}`));
|
|
152
|
+
}
|
|
149
153
|
}
|
|
150
154
|
if (this.#prompt) {
|
|
151
155
|
lines.push(theme.fg("warning", this.#prompt.message));
|
|
@@ -182,6 +186,7 @@ export class SignInTab implements SetupTab {
|
|
|
182
186
|
this.#loggingInProvider = providerId;
|
|
183
187
|
this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
|
|
184
188
|
this.#authUrl = undefined;
|
|
189
|
+
this.#authLaunchUrl = undefined;
|
|
185
190
|
this.#loginAbort = new AbortController();
|
|
186
191
|
this.host.restoreFocus();
|
|
187
192
|
this.host.requestRender();
|
|
@@ -189,7 +194,17 @@ export class SignInTab implements SetupTab {
|
|
|
189
194
|
await this.#authStorage.login(providerId as OAuthProvider, {
|
|
190
195
|
signal: this.#loginAbort.signal,
|
|
191
196
|
onAuth: info => {
|
|
197
|
+
// Store the full authorization URL as the primary copy/display
|
|
198
|
+
// target: it works from any machine, including SSH boxes where
|
|
199
|
+
// the OMP-hosted `launchUrl` would resolve against the user's
|
|
200
|
+
// local browser and fail. The wizard render uses
|
|
201
|
+
// `wrapTextWithAnsi`, so long URLs wrap across lines rather
|
|
202
|
+
// than getting truncated — the RFC 7636 §4.3 PKCE-downgrade
|
|
203
|
+
// bug that motivated `launchUrl` is unreachable through this
|
|
204
|
+
// surface. `launchUrl` is still surfaced as an optional local
|
|
205
|
+
// shortcut for wide-terminal local users.
|
|
192
206
|
this.#authUrl = info.url;
|
|
207
|
+
this.#authLaunchUrl = info.launchUrl && info.launchUrl !== info.url ? info.launchUrl : undefined;
|
|
193
208
|
this.#statusLines = [];
|
|
194
209
|
if (info.instructions) {
|
|
195
210
|
this.#statusLines.push(theme.fg("warning", info.instructions));
|
|
@@ -216,6 +231,7 @@ export class SignInTab implements SetupTab {
|
|
|
216
231
|
theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
|
|
217
232
|
];
|
|
218
233
|
this.#authUrl = undefined;
|
|
234
|
+
this.#authLaunchUrl = undefined;
|
|
219
235
|
this.#loggingInProvider = undefined;
|
|
220
236
|
this.#loginAbort = undefined;
|
|
221
237
|
this.#selector.stopValidation();
|
|
@@ -227,6 +243,7 @@ export class SignInTab implements SetupTab {
|
|
|
227
243
|
if (this.#loginAbort?.signal.aborted) {
|
|
228
244
|
this.#statusLines = [theme.fg("dim", "Login cancelled.")];
|
|
229
245
|
this.#authUrl = undefined;
|
|
246
|
+
this.#authLaunchUrl = undefined;
|
|
230
247
|
} else {
|
|
231
248
|
const message = error instanceof Error ? error.message : String(error);
|
|
232
249
|
this.#statusLines = [
|
|
@@ -234,6 +251,7 @@ export class SignInTab implements SetupTab {
|
|
|
234
251
|
theme.fg("dim", "Choose another provider or press Esc to continue."),
|
|
235
252
|
];
|
|
236
253
|
this.#authUrl = undefined;
|
|
254
|
+
this.#authLaunchUrl = undefined;
|
|
237
255
|
}
|
|
238
256
|
this.#loggingInProvider = undefined;
|
|
239
257
|
this.#loginAbort = undefined;
|