diff --git a/components/ChatWindow.extension-request.test.mjs b/components/ChatWindow.extension-request.test.mjs index d24cf5df9..7272f310c 100644 --- a/components/ChatWindow.extension-request.test.mjs +++ b/components/ChatWindow.extension-request.test.mjs @@ -33,3 +33,21 @@ test("resets collapse state when a new extension request arrives", () => { assert.match(source, / { + assert.match(dialogSource, /alignItems: collapsed \? "flex-start" : "flex-end"/); + assert.match(customSource, /alignItems: collapsed \? "flex-start" : "flex-end"/); + assert.doesNotMatch(dialogSource, /alignItems: collapsed \? "flex-start" : "center"/); + assert.doesNotMatch(customSource, /alignItems: collapsed \? "flex-start" : "center"/); +}); + +test("debounces the completion sound across chained dialogs", () => { + assert.match(source, /EXTENSION_DIALOG_SOUND_MIN_GAP_MS = \d+/); + assert.match(source, /now - extensionDialogLastSoundAtRef\.current < EXTENSION_DIALOG_SOUND_MIN_GAP_MS/); +}); + +test("splits folded context out of the dialog title into the body", () => { + assert.match(dialogSource, /request\.title\.split\(\/\\n\{2,\}\/\)/); + assert.match(dialogSource, /\{headerTitle\}/); + assert.match(dialogSource, /\{bodyTitle\}/); +}); diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 466aa3730..1779674e2 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -85,6 +85,9 @@ function phaseLabel(phase: AgentPhase, t: (key: string, params?: Record(null); + const extensionDialogLastSoundAtRef = useRef(0); const wrappedOnAgentEnd = useCallback(() => { if (completionNotificationsEnabled && soundEnabledRef.current) { playDoneSoundRef.current(); @@ -451,6 +455,9 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi || soundedExtensionDialogIdRef.current === extensionDialog.id ) return; soundedExtensionDialogIdRef.current = extensionDialog.id; + const now = Date.now(); + if (now - extensionDialogLastSoundAtRef.current < EXTENSION_DIALOG_SOUND_MIN_GAP_MS) return; + extensionDialogLastSoundAtRef.current = now; playDoneSoundRef.current(); }, [completionNotificationsEnabled, extensionDialog]); @@ -1467,6 +1474,11 @@ function ExtensionDialog({ const [collapsed, setCollapsed] = useState(false); const [now, setNow] = useState(() => Date.now()); const summary = getExtensionDialogSummary(request); + // Hosts may fold extra context into the title (previews, instructions). The + // first paragraph is the real title; the rest reads better as body text. + const titleParagraphs = request.title.split(/\n{2,}/); + const headerTitle = titleParagraphs[0]; + const bodyTitle = titleParagraphs.slice(1).join("\n\n"); const remainingSeconds = request.expiresAt === undefined ? null : Math.max(0, Math.ceil((request.expiresAt - now) / 1000)); @@ -1505,7 +1517,7 @@ function ExtensionDialog({ inset: 0, zIndex: 90, display: "flex", - alignItems: collapsed ? "flex-start" : "center", + alignItems: collapsed ? "flex-start" : "flex-end", justifyContent: "center", padding: 20, pointerEvents: "none", @@ -1537,7 +1549,7 @@ function ExtensionDialog({ {t("chat.extensionPending")} - {request.title} + {headerTitle} {summary && ( @@ -1568,7 +1580,7 @@ function ExtensionDialog({ >
-
{request.title}
+
{headerTitle}
{t("chat.extensionRequest")} {countdown} @@ -1605,6 +1617,9 @@ function ExtensionDialog({ flex: "1 1 auto", minHeight: 0, overflowY: "auto", }} > + {bodyTitle && ( +
{bodyTitle}
+ )} {request.method === "confirm" && (
{request.message}
)} @@ -1772,7 +1787,7 @@ function ExtensionCustomPanel({ inset: 0, zIndex: 95, display: "flex", - alignItems: collapsed ? "flex-start" : "center", + alignItems: collapsed ? "flex-start" : "flex-end", justifyContent: "center", padding: 20, pointerEvents: "none", diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 9c257b172..4f5668e74 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -341,14 +341,17 @@ export class AgentSessionWrapper { if (typeof this.inner.bindExtensions === "function") { const bindExtensions = this.inner.bindExtensions as (bindings: { uiContext?: ExtensionUiContextLike; - mode?: "rpc"; + mode?: "tui"; commandContextActions?: ExtensionCommandContextActionsLike; shutdownHandler?: () => void; onError?: (error: { extensionPath: string; event: string; error: string }) => void; }) => Promise; await bindExtensions.call(this.inner, { uiContext, - mode: "rpc", + // Advertise "tui": this uiContext implements custom() (terminal-rendered + // panel), so extensions may use their full TUI components instead of + // the select/input fallback they reserve for genuine RPC hosts. + mode: "tui", commandContextActions: this.createExtensionCommandContextActions(), shutdownHandler: () => this.emit({ type: "extension_ui_request", @@ -365,7 +368,7 @@ export class AgentSessionWrapper { }), }); } else { - this.inner.extensionRunner.setUIContext?.(uiContext, "rpc"); + this.inner.extensionRunner.setUIContext?.(uiContext, "tui"); } this.extensionsBound = true; this.applyExactSystemPrompt(); @@ -933,7 +936,7 @@ export class AgentSessionWrapper { await this.inner.reload(); this.setActiveToolSelection(activeToolNames); if (typeof this.inner.bindExtensions !== "function") { - this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "rpc"); + this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "tui"); } this.applyExactSystemPrompt(); invalidateModelsCache(); @@ -1619,7 +1622,7 @@ export class AgentSessionWrapper { this.syncProjectTrust(); await this.inner.reload({ beforeSessionStart: () => { - this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "rpc"); + this.inner.extensionRunner.setUIContext?.(this.createExtensionUiContext(), "tui"); }, }); this.applyExactSystemPrompt();