Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

### Fixed

- Fixed non-overlay `ctx.ui.custom()` components to suppress the global `Working...` loader while Atomic is waiting for user input, then automatically re-enable normal loader behavior when the custom UI settles or is dismissed. Floating `{ overlay: true }` custom UIs continue to leave the loader visible for passive views over active work. ([#1670](https://github.com/bastani-inc/atomic/issues/1670))
- Fixed CLI resolution of unknown/custom model IDs with a recognized `:<thinking>` suffix so the suffix is applied as the thinking level instead of leaking into the synthesized model ID, while preserving registered and unrecognized colon-bearing model IDs.

## [0.9.5-alpha.9] - 2026-07-09
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2420,6 +2420,8 @@ See [github-issue-autocomplete.ts](https://github.com/bastani-inc/atomic/blob/ma

For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called:

Non-overlay custom components (the default, or `{ overlay: false }`) are treated as blocking user input: Atomic suppresses the global `Working...` loader while the component is mounted and restores normal loader behavior when it settles or is dismissed. Use `{ overlay: true }` for passive/floating views that should not automatically suppress the loader.

```typescript
import { Text, type Component } from "@earendil-works/pi-tui";

Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ pi.on("session_start", async (_event, ctx) => {

Pass `{ signal }` to `ctx.ui.custom()` when the UI belongs to an abortable operation. If the signal aborts, Atomic dismisses the custom UI and rejects the returned promise with the signal reason. For overlays, use `options.onHandle` to receive an overlay handle for programmatic visibility control.

While a non-overlay custom UI is mounted (the default, or `{ overlay: false }`), Atomic treats the app as waiting for user input and suppresses the global `Working...` loader. The loader is re-enabled automatically when the custom UI settles or is dismissed. Floating overlays opened with `{ overlay: true }` do not suppress `Working...` automatically because they can be passive views over active work.

## Overlays

Overlays render components on top of existing content without clearing the screen. Pass `{ overlay: true }` to `ctx.ui.custom()`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ InteractiveModeBase.prototype.handleEvent = async function(this: InteractiveMode
this.fallbackLoader = undefined;
}
this.stopWorkingLoader();
if (this.workingVisible) {
if (this.isWorkingLoaderAllowed()) {
this.loadingAnimation = this.createWorkingLoader();
this.statusContainer.addChild(this.loadingAnimation);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ InteractiveModeBase.prototype.notifyHostCustomUiStateListeners = function(this:
InteractiveModeBase.prototype.beginHostInlineCustomUi = function(this: InteractiveModeBase): () => void {
let released = false;
this.blockingInlineCustomUiDepth++;
this.refreshWorkingLoaderVisibility();
this.notifyHostCustomUiStateListeners();
return () => {
if (released) return;
Expand All @@ -50,6 +51,7 @@ InteractiveModeBase.prototype.beginHostInlineCustomUi = function(this: Interacti
this.blockingInlineCustomUiDepth - 1,
);
this.notifyHostCustomUiStateListeners();
this.refreshWorkingLoaderVisibility();
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ InteractiveModeBase.prototype.showWorkingLoaderNow = function(this: InteractiveM
// actually starting. Prompt preflight (extension input hooks, template/skill
// expansion, auth and compaction checks, deferred startup) runs before the
// agent emits `agent_start`, which is otherwise the only place the loader is
// created. Respect `workingVisible` so extensions can still suppress it.
if (!this.workingVisible || this.loadingAnimation) {
// created. Respect extension and host custom-UI suppression.
if (!this.isWorkingLoaderAllowed() || this.loadingAnimation) {
this.ui.requestRender();
return;
}
Expand All @@ -102,9 +102,12 @@ InteractiveModeBase.prototype.showWorkingLoaderNow = function(this: InteractiveM
this.ui.requestRender();
};

InteractiveModeBase.prototype.setWorkingVisible = function(this: InteractiveModeBase, visible: boolean): void {
this.workingVisible = visible;
if (!visible) {
InteractiveModeBase.prototype.isWorkingLoaderAllowed = function(this: InteractiveModeBase): boolean {
return this.workingVisible && this.blockingInlineCustomUiDepth === 0;
};

InteractiveModeBase.prototype.refreshWorkingLoaderVisibility = function(this: InteractiveModeBase): void {
if (!this.isWorkingLoaderAllowed()) {
this.stopWorkingLoader();
this.ui.requestRender();
return;
Expand All @@ -117,6 +120,11 @@ InteractiveModeBase.prototype.setWorkingVisible = function(this: InteractiveMode
this.ui.requestRender();
};

InteractiveModeBase.prototype.setWorkingVisible = function(this: InteractiveModeBase, visible: boolean): void {
this.workingVisible = visible;
this.refreshWorkingLoaderVisibility();
};

InteractiveModeBase.prototype.setWorkingIndicator = function(this: InteractiveModeBase, options?: LoaderIndicatorOptions): void {
this.workingIndicatorOptions = options;
this.loadingAnimation?.setIndicator(options);
Expand Down Expand Up @@ -218,8 +226,13 @@ InteractiveModeBase.prototype.resetExtensionUI = function(this: InteractiveModeB
this.setupAutocompleteProvider();
this.defaultEditor.onExtensionShortcut = undefined;
this.updateTerminalTitle();
this.blockingInlineCustomUiDepth = 0;
this.deferredInlineCustomUiFocusDepth = 0;
this.pendingInlineCustomUiFocus = undefined;
this.notifyHostCustomUiStateListeners();
this.workingMessage = undefined;
this.workingVisible = true;
this.refreshWorkingLoaderVisibility();
this.setWorkingIndicator();
if (this.loadingAnimation) {
this.loadingAnimation.setMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ declare module "./interactive-mode-base.ts" {
createWorkingLoader(): Loader;
stopWorkingLoader(): void;
showWorkingLoaderNow(): void;
isWorkingLoaderAllowed(): boolean;
refreshWorkingLoaderVisibility(): void;
setWorkingVisible(visible: boolean): void;
setWorkingIndicator(options?: LoaderIndicatorOptions): void;
setHiddenThinkingLabel(label?: string): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,29 @@ describe("InteractiveMode.showExtensionCustom host custom UI state", () => {
clear: vi.fn(),
addChild: vi.fn(),
},
statusContainer: {
clear: vi.fn(),
addChild: vi.fn(),
},
loadingAnimation: undefined,
workingVisible: true,
runtimeHost: {
session: { isStreaming: false },
},
keybindings: {},
ui: {
setFocus: vi.fn(),
requestRender: vi.fn(),
},
ui: {
setFocus: vi.fn(),
requestRender: vi.fn(),
showOverlay: vi.fn(() => ({
hide: vi.fn(),
setHidden: vi.fn(),
isHidden: vi.fn(() => false),
focus: vi.fn(),
unfocus: vi.fn(),
isFocused: vi.fn(() => true),
})),
hideOverlay: vi.fn(),
},
blockingInlineCustomUiDepth: 0,
deferredInlineCustomUiFocusDepth: 0,
pendingInlineCustomUiFocus: undefined,
Expand Down Expand Up @@ -161,5 +179,77 @@ describe("InteractiveMode.showExtensionCustom host custom UI state", () => {
{ blockingInlineCustomUiActive: false, blockingInlineCustomUiDepth: 0 },
]);
});

test("suppresses the Working loader while a non-overlay custom UI is active", async () => {
const fakeThis = createCustomUiHostFixture();
const firstLoader = { stop: vi.fn() };
const secondLoader = { stop: vi.fn() };
fakeThis.createWorkingLoader = vi
.fn()
.mockReturnValueOnce(firstLoader)
.mockReturnValueOnce(secondLoader);
const component = {
render: () => [],
invalidate: vi.fn(),
dispose: vi.fn(),
};
let doneCustomUi: ((result: string) => void) | undefined;

fakeThis.showWorkingLoaderNow();
expect(fakeThis.createWorkingLoader).toHaveBeenCalledTimes(1);
expect(fakeThis.loadingAnimation).toBe(firstLoader);

const promise = (InteractiveMode as any).prototype.showExtensionCustom.call(
fakeThis,
(_tui: unknown, _theme: unknown, _keybindings: unknown, done: (result: string) => void) => {
doneCustomUi = done;
return component;
},
);

expect(firstLoader.stop).toHaveBeenCalledTimes(1);
expect(fakeThis.loadingAnimation).toBe(undefined);
fakeThis.runtimeHost.session.isStreaming = true;
fakeThis.showWorkingLoaderNow();
expect(fakeThis.createWorkingLoader).toHaveBeenCalledTimes(1);

await Promise.resolve();
doneCustomUi?.("done");
await expect(promise).resolves.toBe("done");
expect(fakeThis.createWorkingLoader).toHaveBeenCalledTimes(2);
expect(fakeThis.loadingAnimation).toBe(secondLoader);
});

test("does not suppress the Working loader for overlay custom UI", async () => {
const fakeThis = createCustomUiHostFixture();
const loader = { stop: vi.fn() };
fakeThis.createWorkingLoader = vi.fn(() => loader);
const component = {
render: () => [],
invalidate: vi.fn(),
dispose: vi.fn(),
};
let doneCustomUi: ((result: string) => void) | undefined;

fakeThis.showWorkingLoaderNow();
const promise = (InteractiveMode as any).prototype.showExtensionCustom.call(
fakeThis,
(_tui: unknown, _theme: unknown, _keybindings: unknown, done: (result: string) => void) => {
doneCustomUi = done;
return component;
},
{ overlay: true },
);

expect(loader.stop).not.toHaveBeenCalled();
expect(fakeThis.getHostCustomUiState()).toEqual({
blockingInlineCustomUiActive: false,
blockingInlineCustomUiDepth: 0,
});

await Promise.resolve();
doneCustomUi?.("done");
await expect(promise).resolves.toBe("done");
});
});

4 changes: 4 additions & 0 deletions packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- Changed the builtin `ralph` workflow review fan-out from three reviewers to two (`reviewer-a` and `reviewer-b`), removing `reviewer-c` and its GLM-led model chain while keeping unanimous approval across the remaining reviewers.

### Fixed

- Fixed workflow slash-command input surfaces so `/workflow <name>` inline input forms and `/workflow connect`/input/resume pickers mount through host-managed inline custom UI and rely on Atomic's central waiting-for-user-input suppression for the global `Working...` loader, instead of manually toggling loader visibility in each overlay. ([#1670](https://github.com/bastani-inc/atomic/issues/1670))

## [0.9.5-alpha.9] - 2026-07-09

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,7 @@ async function workflowSlashHandler(
const wantsPickerSkip = inputTokens.includes("--no-picker");
let mergedInputs = inputs;
let pickerWasShown = false;
const canOpenPicker = policy.allowInputPicker && !wantsPickerSkip && (
typeof ctx.ui?.setEditorComponent === "function" || typeof ctx.ui?.custom === "function"
);
const canOpenPicker = policy.allowInputPicker && !wantsPickerSkip && typeof ctx.ui?.custom === "function";
if (canOpenPicker) {
await ensureWorkflowResourcesVisible();
const schemaResult = await deps.runtimeForContext(ctx).dispatch({ workflow: workflowName, inputs: {}, action: "inputs" }, { policy });
Expand All @@ -188,9 +186,7 @@ async function workflowSlashHandler(
if (fields.length > 0 && (inputTokens.length === 0 || missingRequired)) {
pickerWasShown = true;
const pickerTheme = deriveGraphTheme({});
let pickerResult = typeof ctx.ui?.setEditorComponent === "function"
? await openInlineInputsForm(pi, ctx, { workflowName, fields, prefilled: inputs, theme: pickerTheme })
: { kind: "unsupported" as const };
let pickerResult = await openInlineInputsForm(pi, ctx, { workflowName, fields, prefilled: inputs, theme: pickerTheme });
if (pickerResult.kind === "unsupported" && typeof ctx.ui?.custom === "function") {
pickerResult = await openInputsPicker(ctx.ui, { workflowName, fields, prefilled: inputs, theme: pickerTheme });
}
Expand Down
7 changes: 3 additions & 4 deletions packages/workflows/src/tui/inline-form-editor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Custom `EditorComponent` swapped in via `ctx.ui.setEditorComponent` while
* an inline workflow form is active. Owns ALL keystrokes during fill-out:
* Custom `EditorComponent` mounted as non-overlay `ctx.ui.custom()` while an
* inline workflow form is active. Owns ALL keystrokes during fill-out:
*
* tab / shift+tab — move focus across form fields and the final Submit action
* ↑/↓ — move focus (or caret between logical lines in `text`)
Expand All @@ -27,8 +27,7 @@
*
* On submit/cancel the editor calls back to the orchestrator which:
* 1. Marks the form state finalized (renderer flips to frozen view)
* 2. Restores the previously-installed editor via `setEditorComponent`
* 3. Resolves the open() promise so the slash command can proceed
* 2. Resolves the host custom UI so the slash command can proceed
*
* Render: intentionally returns no rows. The chat-history card is the single
* visible editing surface; this component is a headless keystroke router so
Expand Down
Loading
Loading