Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions components/ChatWindow.extension-request.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,21 @@ test("resets collapse state when a new extension request arrives", () => {
assert.match(source, /<ExtensionCustomPanel key=\{extensionCustomUi.id\}/);
assert.match(customSource, /if \(!collapsed\) inputRef.current\?\.focus\(\);\s*}, \[collapsed\]\)/);
});

test("docks extension overlays to the bottom so the chat stays scrollable", () => {
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\}/);
});
23 changes: 19 additions & 4 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ function phaseLabel(phase: AgentPhase, t: (key: string, params?: Record<string,

const CHAT_MINIMAP_WIDTH = 36;
const CHAT_COLUMN_PADDING = 16;
// A dialog replacing another one within this window is a single interaction
// (e.g. select followed by a free-text input) and must not re-ring.
const EXTENSION_DIALOG_SOUND_MIN_GAP_MS = 2000;

function NewSessionUpdateLink({
label,
Expand Down Expand Up @@ -252,6 +255,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
const soundEnabledRef = useRef(soundEnabled);
soundEnabledRef.current = soundEnabled;
const soundedExtensionDialogIdRef = useRef<string | null>(null);
const extensionDialogLastSoundAtRef = useRef(0);
const wrappedOnAgentEnd = useCallback(() => {
if (completionNotificationsEnabled && soundEnabledRef.current) {
playDoneSoundRef.current();
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1537,7 +1549,7 @@ function ExtensionDialog({
{t("chat.extensionPending")}
</span>
<span style={{ fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
{request.title}
{headerTitle}
</span>
{summary && (
<span style={{ fontSize: 12, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "34%", flexShrink: 1 }}>
Expand Down Expand Up @@ -1568,7 +1580,7 @@ function ExtensionDialog({
>
<div style={{ flexShrink: 0, display: "flex", alignItems: "flex-start", gap: 8, padding: "12px 14px", borderBottom: "1px solid var(--border)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "var(--text)", fontSize: 14, fontWeight: 650 }}>{request.title}</div>
<div style={{ color: "var(--text)", fontSize: 14, fontWeight: 650 }}>{headerTitle}</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 3, color: "var(--text-dim)", fontSize: 11, fontFamily: "var(--font-mono)" }}>
<span>{t("chat.extensionRequest")}</span>
{countdown}
Expand Down Expand Up @@ -1605,6 +1617,9 @@ function ExtensionDialog({
flex: "1 1 auto", minHeight: 0, overflowY: "auto",
}}
>
{bodyTitle && (
<div style={{ color: "var(--text-muted)", fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap", marginBottom: 12, overflowWrap: "anywhere" }}>{bodyTitle}</div>
)}
{request.method === "confirm" && (
<div style={{ color: "var(--text-muted)", fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{request.message}</div>
)}
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 8 additions & 5 deletions lib/rpc-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
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",
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading