Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed extension custom UI focus deferral so full-screen overlays can keep keyboard focus while a parent/main-chat inline custom UI is pending, then focus that pending UI when the overlay is hidden; already-aborted custom UI calls no longer invoke factories or emit host custom-UI state changes ([#1353](https://github.com/bastani-inc/atomic/issues/1353)).

## [0.8.28] - 2026-06-11

### Added
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type {
GetAllToolsHandler,
GetCommandsHandler,
GetThinkingLevelHandler,
HostCustomUiState,
HostCustomUiStateListener,
GrepToolCallEvent,
GrepToolResultEvent,
// Events - Input
Expand Down
23 changes: 23 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ export interface ChatRenderSettings {
getCustomMessageRenderer(customType: string): MessageRenderer | undefined;
}

/** Host-owned inline custom UI focus state exposed to overlays without prompt content. */
export interface HostCustomUiState {
/** Number of active non-overlay host custom UI mounts. */
blockingInlineCustomUiDepth: number;
/** True when at least one non-overlay host custom UI is mounted and blocking. */
blockingInlineCustomUiActive: boolean;
/** True when the active inline custom UI is waiting behind an overlay that kept focus. */
blockingInlineCustomUiFocusDeferred?: boolean;
}

export type HostCustomUiStateListener = (state: HostCustomUiState) => void;

/**
* UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation.
Expand All @@ -169,6 +181,15 @@ export interface ExtensionUIContext {
/** Request an interactive repaint after extension-owned state changes. */
requestRender(): void;

/** Get host-owned inline custom UI focus state, if the mode exposes it. */
getHostCustomUiState?(): HostCustomUiState;

/** Observe host-owned inline custom UI focus state changes. Returns an unsubscribe function. */
onHostCustomUiStateChange?(listener: HostCustomUiStateListener): () => void;

/** Move focus to a mounted host-owned inline custom UI, if one is pending. */
focusHostInlineCustomUi?(): boolean;

/** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */
onTerminalInput(handler: TerminalInputHandler): () => void;

Expand Down Expand Up @@ -230,6 +251,8 @@ export interface ExtensionUIContext {
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: {
overlay?: boolean;
/** Keep host inline custom UI pending in the background while this overlay is visible. */
deferInlineCustomUiFocus?: boolean;
/** AbortSignal to programmatically dismiss the custom UI. */
signal?: AbortSignal;
/** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */
Expand Down
180 changes: 171 additions & 9 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ import type {
ExtensionRunner,
ExtensionUIContext,
ExtensionUIDialogOptions,
HostCustomUiState,
HostCustomUiStateListener,
ProjectTrustContext,
ExtensionWidgetOptions,
} from "../../core/extensions/index.ts";
Expand Down Expand Up @@ -440,6 +442,10 @@ export class InteractiveMode {
private extensionInput: ExtensionInputComponent | undefined = undefined;
private extensionEditor: ExtensionEditorComponent | undefined = undefined;
private extensionTerminalInputUnsubscribers = new Set<() => void>();
private blockingInlineCustomUiDepth = 0;
private deferredInlineCustomUiFocusDepth = 0;
private pendingInlineCustomUiFocus: Component | undefined = undefined;
private hostCustomUiStateListeners = new Set<HostCustomUiStateListener>();

// Extension widgets (components rendered above/below the editor)
private extensionWidgetsAbove = new Map<
Expand Down Expand Up @@ -2515,6 +2521,80 @@ export class InteractiveMode {
this.extensionTerminalInputUnsubscribers.clear();
}

private getHostCustomUiState(): HostCustomUiState {
const focusDeferred =
this.blockingInlineCustomUiDepth > 0 && this.pendingInlineCustomUiFocus !== undefined;
return {
blockingInlineCustomUiDepth: this.blockingInlineCustomUiDepth,
blockingInlineCustomUiActive: this.blockingInlineCustomUiDepth > 0,
...(focusDeferred ? { blockingInlineCustomUiFocusDeferred: true } : {}),
};
}

private notifyHostCustomUiStateListeners(): void {
const state = this.getHostCustomUiState();
for (const listener of this.hostCustomUiStateListeners) {
try {
listener(state);
} catch {
/* ignore observer errors */
}
}
}

private beginHostInlineCustomUi(): () => void {
let released = false;
this.blockingInlineCustomUiDepth++;
this.notifyHostCustomUiStateListeners();
return () => {
if (released) return;
released = true;
this.blockingInlineCustomUiDepth = Math.max(
0,
this.blockingInlineCustomUiDepth - 1,
);
this.notifyHostCustomUiStateListeners();
};
}

private beginInlineCustomUiFocusDeferral(): () => void {
let released = false;
this.deferredInlineCustomUiFocusDepth++;
return () => {
if (released) return;
released = true;
this.deferredInlineCustomUiFocusDepth = Math.max(
0,
this.deferredInlineCustomUiFocusDepth - 1,
);
if (this.deferredInlineCustomUiFocusDepth === 0) {
this.focusHostInlineCustomUi();
}
};
}

private shouldDeferInlineCustomUiFocus(): boolean {
return this.deferredInlineCustomUiFocusDepth > 0;
}

private focusHostInlineCustomUi(): boolean {
const component = this.pendingInlineCustomUiFocus;
if (component === undefined) return false;
this.pendingInlineCustomUiFocus = undefined;
this.ui.setFocus(component);
this.ui.requestRender();
this.notifyHostCustomUiStateListeners();
return true;
}

private onHostCustomUiStateChange(
listener: HostCustomUiStateListener,
): () => void {
this.hostCustomUiStateListeners.add(listener);
return () => {
this.hostCustomUiStateListeners.delete(listener);
};
}

private createProjectTrustContext(cwd: string): ProjectTrustContext {
const ui = this.createExtensionUIContext();
Expand Down Expand Up @@ -2544,6 +2624,10 @@ export class InteractiveMode {
this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type),
requestRender: () => this.ui.requestRender(),
getHostCustomUiState: () => this.getHostCustomUiState(),
onHostCustomUiStateChange: (listener) =>
this.onHostCustomUiStateChange(listener),
focusHostInlineCustomUi: () => this.focusHostInlineCustomUi(),
onTerminalInput: (handler) =>
this.addExtensionTerminalInputListener(handler),
setStatus: (key, text) => this.setExtensionStatus(key, text),
Expand Down Expand Up @@ -2899,6 +2983,7 @@ export class InteractiveMode {
| Promise<Component & { dispose?(): void }>,
options?: {
overlay?: boolean;
deferInlineCustomUiFocus?: boolean;
signal?: AbortSignal;
overlayOptions?: OverlayOptions | (() => OverlayOptions);
onHandle?: (handle: OverlayHandle) => void;
Expand All @@ -2907,18 +2992,20 @@ export class InteractiveMode {
const savedText = this.editor.getText();
const isOverlay = options?.overlay ?? false;

const restoreEditor = () => {
const restoreEditor = (focusEditor: boolean) => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
if (focusEditor) this.ui.setFocus(this.editor);
this.ui.requestRender();
};

return new Promise((resolve, reject) => {
let component: (Component & { dispose?(): void }) | undefined;
let closed = false;
let mounted = false;
let releaseHostInlineCustomUi: (() => void) | undefined;
let releaseOverlayInlineCustomUiFocusDeferral: (() => void) | undefined;

const disposeComponent = () => {
try {
Expand All @@ -2928,23 +3015,40 @@ export class InteractiveMode {
}
};

const releaseHostCustomUi = () => {
if (component !== undefined && this.pendingInlineCustomUiFocus === component) {
this.pendingInlineCustomUiFocus = undefined;
this.notifyHostCustomUiStateListeners();
}
releaseHostInlineCustomUi?.();
};

const cleanupAbortListener = () => {
options?.signal?.removeEventListener("abort", abortCustomUi);
};

const closeMountedUi = () => {
if (!mounted) return;
if (isOverlay) this.ui.hideOverlay();
else restoreEditor();
if (isOverlay) {
releaseOverlayInlineCustomUiFocusDeferral?.();
releaseOverlayInlineCustomUiFocusDeferral = undefined;
this.ui.hideOverlay();
} else {
restoreEditor(
!this.shouldDeferInlineCustomUiFocus() &&
this.pendingInlineCustomUiFocus !== component,
);
}
};

const close = (result: T) => {
if (closed) return;
closed = true;
cleanupAbortListener();
closeMountedUi();
resolve(result);
disposeComponent();
releaseHostCustomUi();
resolve(result);
};

const rejectAndClose = (reason: unknown) => {
Expand All @@ -2953,20 +3057,38 @@ export class InteractiveMode {
cleanupAbortListener();
closeMountedUi();
disposeComponent();
releaseHostCustomUi();
reject(reason);
};

function abortCustomUi(): void {
rejectAndClose(options?.signal?.reason ?? new Error("Extension custom UI aborted"));
}

if (options?.signal?.aborted) {
abortCustomUi();
return;
}
releaseHostInlineCustomUi = isOverlay
? undefined
: this.beginHostInlineCustomUi();
if (options?.signal?.aborted) {
abortCustomUi();
return;
}
options?.signal?.addEventListener("abort", abortCustomUi, { once: true });

Promise.resolve(factory(this.ui, theme, this.keybindings, close))
let factoryResult:
| (Component & { dispose?(): void })
| Promise<Component & { dispose?(): void }>;
try {
factoryResult = factory(this.ui, theme, this.keybindings, close);
} catch (err) {
rejectAndClose(err);
return;
}

Promise.resolve(factoryResult)
.then((c) => {
if (closed) {
try {
Expand All @@ -2993,12 +3115,52 @@ export class InteractiveMode {
};
const handle = this.ui.showOverlay(component, resolveOptions());
mounted = true;
// Expose handle to caller for visibility control
options?.onHandle?.(handle);
if (options?.deferInlineCustomUiFocus) {
let releaseDeferral: (() => void) | undefined = this.beginInlineCustomUiFocusDeferral();
releaseOverlayInlineCustomUiFocusDeferral = () => {
releaseDeferral?.();
releaseDeferral = undefined;
};
const release = () => {
releaseOverlayInlineCustomUiFocusDeferral?.();
releaseOverlayInlineCustomUiFocusDeferral = undefined;
};
const wrappedHandle: OverlayHandle = {
hide: () => {
release();
handle.hide();
},
setHidden: (hidden) => {
if (hidden) release();
handle.setHidden(hidden);
if (!hidden && releaseDeferral === undefined) {
releaseDeferral = this.beginInlineCustomUiFocusDeferral();
releaseOverlayInlineCustomUiFocusDeferral = () => {
releaseDeferral?.();
releaseDeferral = undefined;
};
}
},
isHidden: () => handle.isHidden(),
focus: () => handle.focus(),
unfocus: (unfocusOptions) => handle.unfocus(unfocusOptions),
isFocused: () => handle.isFocused(),
};
// Expose handle to caller for visibility control
options?.onHandle?.(wrappedHandle);
} else {
// Expose handle to caller for visibility control
options?.onHandle?.(handle);
}
} else {
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
if (this.shouldDeferInlineCustomUiFocus()) {
this.pendingInlineCustomUiFocus = component;
this.notifyHostCustomUiStateListeners();
} else {
this.ui.setFocus(component);
}
mounted = true;
this.ui.requestRender();
}
Expand Down
Loading
Loading