fix(coding-agent): close own custom ui overlay (#10999)

done() from ctx.ui.custom({ overlay: true }) closed the overlay with
ui.hideOverlay(), which removes the topmost overlay instead of the one
that called done(). With a shell overlay open under a dialog overlay
from another extension, the shell's exit removed the dialog: the
finished shell overlay stayed on screen ignoring input and the dialog's
promise never resolved. Hide the overlay through its own handle.

closes #10648
This commit is contained in:
Colin Mason 2026-10-15 14:50:03 -04:00 • committed by GitHub
parent d05a73fb31
commit a4f67eb65e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 83 additions and 3 deletions

View file

@ -2989,12 +2989,14 @@ export class InteractiveMode {
return new Promise((resolve, reject) => {
let component: Component & { dispose?(): void };
let overlayHandle: OverlayHandle | undefined;
let closed = false;
const close = (result: T) => {
if (closed) return;
closed = true;
if (isOverlay) this.ui.hideOverlay();
// Remove this overlay, not the topmost one: another overlay may have opened above it.
if (isOverlay) overlayHandle?.hide();
else restoreEditor();
// Note: both branches above already call requestRender
resolve(result);
@ -3023,9 +3025,9 @@ export class InteractiveMode {
const w = (component as { width?: number }).width;
return w ? { width: w } : undefined;
};
const handle = this.ui.showOverlay(component, resolveOptions());
overlayHandle = this.ui.showOverlay(component, resolveOptions());
// Expose handle to caller for visibility control
options?.onHandle?.(handle);
options?.onHandle?.(overlayHandle);
} else {
this.disposeActiveSelector();
this.editorContainer.clear();

View file

@ -300,6 +300,84 @@ describe("InteractiveMode.showExtensionCustom", () => {
ui.stop();
}
});
// Regression test for #10648: closing a lower overlay removed the topmost overlay instead.
test.each([
{ lowerKind: "capturing", nonCapturing: false },
{ lowerKind: "non-capturing", nonCapturing: true },
])(
"closing a lower $lowerKind overlay custom UI removes its own overlay, not the topmost one",
async ({ nonCapturing }) => {
const terminal = new VirtualTerminal(80, 24);
const ui: TUI = new TuiMainScreen(terminal);
const editorContainer = new Container();
const editor = new TestFocusableComponent("EDITOR");
const lower = new TestFocusableComponent("LOWER");
const upper = new TestFocusableComponent("UPPER");
let closeLower: (value: string) => void = () => {
throw new Error("closeLower was not initialized");
};
let closeUpper: (value: string) => void = () => {
throw new Error("closeUpper was not initialized");
};
const fakeThis = {
editor,
editorContainer,
keybindings: {},
ui,
disposeActiveSelector: vi.fn(),
};
const showExtensionCustom = <T>(
factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { overlay?: boolean; overlayOptions?: { nonCapturing?: boolean } },
): Promise<T> =>
(InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise<T>;
editorContainer.addChild(editor);
ui.addChild(editorContainer);
ui.setFocus(editor);
ui.start();
try {
const lowerPromise = showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
closeLower = done;
return lower;
},
{ overlay: true, overlayOptions: { nonCapturing } },
);
await flushTui(ui, terminal);
const upperPromise = showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
closeUpper = done;
return upper;
},
{ overlay: true },
);
await flushTui(ui, terminal);
expect(upper.focused).toBe(true);
closeLower("lower closed");
await expect(lowerPromise).resolves.toBe("lower closed");
await flushTui(ui, terminal);
const viewport = terminal.getViewport().join("\n");
expect(viewport).toContain("UPPER");
expect(viewport).not.toContain("LOWER");
terminal.sendInput("x");
await flushTui(ui, terminal);
expect(upper.inputs).toEqual(["x"]);
expect(lower.inputs).toEqual([]);
closeUpper("upper closed");
await expect(upperPromise).resolves.toBe("upper closed");
await flushTui(ui, terminal);
expect(ui.hasOverlay()).toBe(false);
expect(editor.focused).toBe(true);
} finally {
ui.stop();
}
},
);
});
describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => {