Skip to content
Merged
34 changes: 34 additions & 0 deletions docs/src/content/docs/docs/Advanced/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,40 @@ acknowledgement, `form` → an object mapping each field's `id` to its string
value (date fields use the `@date:ISO` format). The run's outcome arrives as the
`done`/`error` poll event.

### Cancelling

`{"cancelled":true}` is how you say *the user dismissed this prompt*. It aborts
the run exactly as pressing Escape on the in-app dialog does.

Do not use it just to close an `info` panel. `info` is the one prompt that cannot
be cancelled in the app - the dialog has no reject path - so cancelling it
remotely aborts a run that would have continued. Send a plain reply instead. It
stays cancellable because it is a client's only explicit way to bail out mid-run.

### When a reply is rejected

:::note[Available in the next release]
The `400`-and-retry semantics below are new. Before them, a `cancelled` flag that was not the
literal `true` was consumed as a cancellation, and a `confirm` prompt with no value was read as
"No".
:::

`/reply` answers `400` and leaves the prompt **pending** when it cannot honour
what you sent, so you can correct the reply and POST again. Two cases:

Comment thread
chhoumann marked this conversation as resolved.
- `cancelled` is present but is not a boolean (`"true"`, `1`, `"no"`). QuickAdd
will neither abort on a flag it does not recognise nor quietly answer the prompt
on the user's behalf, so it asks you to fix the flag. Use the literal `true`;
`false` and omitting it both mean "this is a real answer".
- a `confirm` reply whose `value` is not `true`/`false`. The user never answered,
and QuickAdd will not invent a "No" for them.

Every other prompt type accepts whatever you send, including an empty answer:
`""` and `[]` are things a user genuinely submits in the app (the Skip buttons,
optional fields), so they must stay legal here too.

A `409` means nothing was waiting on that `requestId`.

Good to know:

- **Desktop only.** The bridge binds to `127.0.0.1`, is gated by the per-session `token`, rejects browser (`Origin`/`Referer`) and non-loopback `Host` requests, and the server is ephemeral - it starts on the first session and stops when the last one ends.
Expand Down
48 changes: 33 additions & 15 deletions docs/src/content/docs/docs/QuickAddAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,29 +936,47 @@ module.exports = async (params) => {

## Error Handling Best Practices

Always wrap API calls in try-catch blocks:
:::note[Available in the next release]
Until then, a prompt that failed for a reason other than the user cancelling resolved
`undefined` instead of rejecting, so a script could not tell a failure from an empty answer.
:::

A prompt has exactly two non-happy outcomes, and they are different things:

- **The user dismissed it** (Escape, Cancel, closing the dialog). The promise rejects with `MacroAbortError("Input cancelled by user")`. Let it bubble and QuickAdd stops the run quietly - which is almost always what you want.
- **Something broke** (a bug in your script, a vault error). The promise rejects with that error. Let it bubble and QuickAdd reports it once, so you get a notice and a console entry to debug from.

A prompt never resolves `undefined` to mean either of those. An empty string means the user submitted an empty answer, which is a real answer.

```javascript
module.exports = async (params) => {
const { quickAddApi } = params;


// The simplest correct script: no try/catch at all. A dismissal stops the
// macro; a real failure is reported. Only catch if you need to do something
// in between.
const input = await quickAddApi.inputPrompt("Enter value:");
if (input === "") return; // submitted empty on purpose

// Process input...
};
```

If your script does need to react to a cancellation, tell the two apart by the error name:

```javascript
module.exports = async (params) => {
const { quickAddApi } = params;

try {
const input = await quickAddApi.inputPrompt("Enter value:");

if (!input) {
// User cancelled - handle gracefully
return;
}

// Process input...

} catch (error) {
console.error("Script error:", error);

await quickAddApi.infoDialog(
"Error",
`An error occurred: ${error.message}`
);
if (error?.name === "MacroAbortError") {
// The user backed out. Clean up and stop.
return;
}
throw error; // a real failure - let QuickAdd report it
}
};
```
Expand Down
15 changes: 9 additions & 6 deletions src/engine/MacroChoiceEngine.entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,11 +874,14 @@ describe("QuickAddApi prompt cancellation", () => {
).rejects.toThrow(MacroAbortError);
});

it("still resolves undefined for other prompt errors", async () => {
mockInputPrompt.mockRejectedValueOnce(new Error("boom"));

await expect(
QuickAddApi.inputPrompt(app, "Enter value"),
).resolves.toBeUndefined();
// A genuine failure must NOT come back as "the user gave no input" (#1575):
// the script would carry on and act on a value that was never entered.
it("propagates other prompt errors instead of resolving undefined", async () => {
const failure = new Error("boom");
mockInputPrompt.mockRejectedValueOnce(failure);

await expect(QuickAddApi.inputPrompt(app, "Enter value")).rejects.toBe(
failure,
);
});
});
34 changes: 31 additions & 3 deletions src/engine/runTemplateFromFolder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type QuickAdd from "../main";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import type ITemplateChoice from "../types/choices/ITemplateChoice";
import { VALUE_SYNTAX } from "../constants";
import { promptCancelled } from "../errors/UserCancelError";
import { log } from "../logger/logManager";

vi.mock("../gui/GenericSuggester/genericSuggester", () => ({
default: { Suggest: vi.fn() },
Expand Down Expand Up @@ -171,10 +173,16 @@ describe("runTemplateFromFolder", () => {
expect(choice.templatePath).toBe("Templates/Daily.md");
});

it("does not execute when the picker is cancelled", async () => {
// The `isCancellationError -> return null` shape, driven with what the picker
// ACTUALLY rejects with since #1577. The legacy string is kept as a second case
// because a user script can still throw one and the contract honours that.
it.each([
["a typed dismissal", () => promptCancelled()],
["a legacy sentinel a user script may throw", () => "no input given."],
])("does not execute when the picker is cancelled with %s", async (_label, make) => {
const logError = vi.spyOn(log, "logError").mockImplementation(() => {});
const executor = createExecutor();
// GenericSuggester rejects with this exact reason on dismissal.
suggestMock.mockRejectedValueOnce("no input given.");
suggestMock.mockRejectedValueOnce(make());
await runTemplateFromFolder(
app,
createPlugin({
Expand All @@ -184,6 +192,26 @@ describe("runTemplateFromFolder", () => {
{ choiceExecutor: executor },
);
expect(executor.execute).not.toHaveBeenCalled();
// Quietly: backing out of the picker is not an error to report.
expect(logError).not.toHaveBeenCalled();
logError.mockRestore();
});

it("reports a genuine picker failure instead of failing silently", async () => {
const logError = vi.spyOn(log, "logError").mockImplementation(() => {});
const executor = createExecutor();
suggestMock.mockRejectedValueOnce(new Error("index unavailable"));
await runTemplateFromFolder(
app,
createPlugin({
templateFolderPaths: ["Templates"],
templateFiles: [tfile("Templates/Daily.md")],
}),
{ choiceExecutor: executor },
);
expect(executor.execute).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledTimes(1);
logError.mockRestore();
});
});

Expand Down
24 changes: 24 additions & 0 deletions src/errors/UserCancelError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,27 @@ import { MacroAbortError } from "./MacroAbortError";
* error name continue to recognise it.
*/
export class UserCancelError extends MacroAbortError {}

/**
* The one message every prompt uses when the user dismisses it. Matches the text the
* public docs promise (`MacroAbortError("Input cancelled by user")`), so a script that
* surfaces `error.message` reads the same whether the prompt was dismissed in the app,
* dismissed remotely, or converted from a legacy sentinel on the way up.
*/
export const PROMPT_CANCELLED_MESSAGE = "Input cancelled by user";

/**
* The cancellation a prompt throws when the user dismisses it (Escape / Cancel / close).
*
* Prompts used to reject with a bare English sentence, recognised by exact string
* equality (see the legacy sentinel list in `errorUtils`) — so rewording one, or
* adding a prompt with a slightly different
* sentence, silently turned "the user cancelled" into "an error occurred" with no compiler
* or test signal (#1577). This is the typed replacement, and it is deliberately the SAME
* class the ~40 consumers already converted that string into, rather than a new one: a
* dismissal now arrives already classified, so a consumer that forgets to convert it still
* behaves correctly.
*/
export function promptCancelled(): UserCancelError {
return new UserCancelError(PROMPT_CANCELLED_MESSAGE);
}
14 changes: 9 additions & 5 deletions src/formatters/previewDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,18 @@ function stripBrandPrefix(message: string): string {
export function describePreviewFailure(error: unknown): string | null {
// A cancelled prompt is a user action, not an authoring mistake. The preview
// formatters never prompt, but a nested resolver still can. Check the RAW
// value first: QuickAdd's modals reject with a bare string
// (`rejectPromise("No input given.")`), and `isCancellationError` only ever
// matches strings - so an `instanceof Error` gate ahead of it would let every
// cancellation through as "Preview unavailable".
// value first, before the `instanceof Error` gate below: a dismissal is a
// typed UserCancelError (#1577), and letting it reach that gate would report
// every cancellation as "Preview unavailable".
if (isCancellationError(error)) return null;
if (!(error instanceof Error)) return PREVIEW_FAILED_MESSAGE;
const message = error.message ?? "";
// Also covers a cancellation that was wrapped in an Error on the way up.
// Covers a USER SCRIPT that throws an Error whose message is one of the legacy
// sentinels (see `isCancellationError`) - the shape a script written against the
// pre-#1577 strings still uses. It deliberately does NOT cover
// `toError(err, context)`, which prefixes the context and so matches no sentinel;
// adding the canonical message to that set would not fix it either, and nothing
// wraps on the way into this function.
if (isCancellationError(message)) return null;
// By far the most frequent throw: the token autocomplete inserts `{{VALUE:}}`
// with the caret between the colon and the braces, and VARIABLE_REGEX matches
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { App } from "obsidian";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { UserCancelError } from "../../errors/UserCancelError";

// Mock obsidian with a local Modal/ButtonComponent/ToggleComponent so
// super.onClose() resolves (the shared stub's Modal omits onClose on the
Expand Down Expand Up @@ -161,15 +162,15 @@ describe("GenericCheckboxPrompt header + cancel (audit: prompts-gui-checkbox-pro
expect(() => buttonByText(prompt, "Cancel")).not.toThrow();
});

it("Cancel rejects the promise (no input given)", async () => {
it("Cancel rejects the promise with a typed cancellation", async () => {
const prompt = new GenericCheckboxPrompt(app, ["a", "b"], ["a"]);
const promise = prompt.promise;

buttonByText(prompt, "Cancel").dispatchEvent(
new Event("click", { bubbles: true }),
);

await expect(promise).rejects.toBe("no input given.");
await expect(promise).rejects.toBeInstanceOf(UserCancelError);
});

it("Submit resolves the selected items", async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/gui/GenericCheckboxPrompt/genericCheckboxPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { App } from "obsidian";
import { ButtonComponent, Modal, ToggleComponent } from "obsidian";
import { promptCancelled } from "../../errors/UserCancelError";

export default class GenericCheckboxPrompt extends Modal {
private resolvePromise: (value: string[]) => void;
Expand Down Expand Up @@ -54,7 +55,7 @@ export default class GenericCheckboxPrompt extends Modal {
onClose() {
super.onClose();

if (!this.resolved) this.rejectPromise("no input given.");
if (!this.resolved) this.rejectPromise(promptCancelled());
}

private addCheckboxRows() {
Expand Down
3 changes: 2 additions & 1 deletion src/gui/GenericInputPrompt/GenericInputPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { positionInputPromptCursor } from "../inputPromptCursor";
import { renderPromptContextLine } from "../promptContextLine";
import type { ImagePasteHandle } from "../imagePasteHandler";
import { attachImagePasteHandler } from "../imagePasteHandler";
import { promptCancelled } from "../../errors/UserCancelError";

/**
* The keyboard gesture that skips an optional prompt: ctrl/cmd+shift+Enter.
Expand Down Expand Up @@ -290,7 +291,7 @@ export default class GenericInputPrompt extends Modal {
}

private resolveInput() {
if (!this.didSubmit) this.rejectPromise("No input given.");
if (!this.didSubmit) this.rejectPromise(promptCancelled());
else this.resolvePromise(this.input);
}

Expand Down
3 changes: 2 additions & 1 deletion src/gui/GenericSuggester/genericSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeDisplayItem,
normalizeQuery,
} from "../suggesters/utils";
import { promptCancelled } from "../../errors/UserCancelError";

type SuggestRender<T> = (value: T, el: HTMLElement) => void;

Expand Down Expand Up @@ -160,7 +161,7 @@ export default class GenericSuggester<T> extends FuzzySuggestModal<T> {
onClose() {
super.onClose();

if (!this.resolved) this.rejectPromise("no input given.");
if (!this.resolved) this.rejectPromise(promptCancelled());
}

private warnIfEmptyDisplay(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Modal } from "obsidian";
import type QuickAdd from "../../main";
import { setQuickAddInstance } from "../../quickAddInstance";
import GenericWideInputPrompt from "./GenericWideInputPrompt";
import { UserCancelError } from "../../errors/UserCancelError";

// The obsidian-stub Modal does not implement onOpen/onClose; the prompt calls
// super.onOpen()/super.onClose(). Provide no-ops so construction and close do not
Expand Down Expand Up @@ -220,7 +221,7 @@ describe("image paste submit/cancel races (issue #1484)", () => {
).find((button) => button.textContent === "Cancel") as HTMLButtonElement;
cancelButton.click();

await expect(waitForClose).rejects.toBe("No input given.");
await expect(waitForClose).rejects.toBeInstanceOf(UserCancelError);

// The save landing later must NOT resurrect the submit on the closed
// modal (deferred submit is guarded by didClose).
Expand Down
3 changes: 2 additions & 1 deletion src/gui/GenericWideInputPrompt/GenericWideInputPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { attachTextareaIndent } from "../components/textareaIndent";
import { isSkipPromptShortcut } from "../GenericInputPrompt/GenericInputPrompt";
import type { ImagePasteHandle } from "../imagePasteHandler";
import { attachImagePasteHandler } from "../imagePasteHandler";
import { promptCancelled } from "../../errors/UserCancelError";

export default class GenericWideInputPrompt extends Modal {
public waitForClose: Promise<string>;
Expand Down Expand Up @@ -281,7 +282,7 @@ export default class GenericWideInputPrompt extends Modal {
}

private resolveInput() {
if (!this.didSubmit) this.rejectPromise("No input given.");
if (!this.didSubmit) this.rejectPromise(promptCancelled());
else this.resolvePromise(this.input);
}

Expand Down
3 changes: 2 additions & 1 deletion src/gui/InputSuggester/inputSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeDisplayItem,
normalizeQuery,
} from "../suggesters/utils";
import { promptCancelled } from "../../errors/UserCancelError";

type SuggestRender<T> = (value: T, el: HTMLElement) => void;

Expand Down Expand Up @@ -275,7 +276,7 @@ export default class InputSuggester extends FuzzySuggestModal<string> {
onClose() {
super.onClose();

if (!this.resolved) this.rejectPromise("no input given.");
if (!this.resolved) this.rejectPromise(promptCancelled());
}

private warnIfEmptyDisplay(): void {
Expand Down
3 changes: 2 additions & 1 deletion src/gui/MathModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { getQuickAddInstance } from "../quickAddInstance";
import { LATEX_CURSOR_MOVE_HERE } from "../LaTeXSymbols";
import { LaTeXSuggester } from "./suggesters/LaTeXSuggester";
import { promptCancelled } from "../errors/UserCancelError";

export class MathModal extends Modal {
public waitForClose: Promise<string>;
Expand Down Expand Up @@ -154,7 +155,7 @@ export class MathModal extends Modal {
const output = this.inputEl.value
.replace(/\\n/g, `\\\\n`)
.replace(new RegExp(LATEX_CURSOR_MOVE_HERE, "g"), "");
if (!this.didSubmit) this.rejectPromise("No input given.");
if (!this.didSubmit) this.rejectPromise(promptCancelled());
else this.resolvePromise(output);
}

Expand Down
3 changes: 2 additions & 1 deletion src/gui/MultiChoiceSettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { App } from "obsidian";
import { ButtonComponent, Modal, Setting } from "obsidian";
import type IMultiChoice from "../types/choices/IMultiChoice";
import { addChoiceIconSetting } from "./ChoiceBuilder/components/choiceIconSetting";
import { promptCancelled } from "../errors/UserCancelError";

export class MultiChoiceSettingsModal extends Modal {
public waitForClose: Promise<IMultiChoice | undefined>;
Expand Down Expand Up @@ -88,7 +89,7 @@ export class MultiChoiceSettingsModal extends Modal {
onClose() {
super.onClose();
if (!this.didSubmit) {
this.rejectPromise("No input given.");
this.rejectPromise(promptCancelled());
return;
}

Expand Down
Loading