Skip to content

fix(ai): stop previewing a substitution the system prompt never gets#1581

Merged
chhoumann merged 4 commits into
masterfrom
chhoumann/1565-modal-inert-previews
Jul 27, 2026
Merged

fix(ai): stop previewing a substitution the system prompt never gets#1581
chhoumann merged 4 commits into
masterfrom
chhoumann/1565-modal-inert-previews

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #1565. Closes #1568.

The change is not what #1565 asked for, and here is why

#1565 asked for four imperative modals to be brought under the inert-preview
mechanism #1560 built for the choice builders. Three of the four should not have
a format preview at all.

The AI system prompt is sent to the model verbatim. Only the prompt (or
prompt template) goes through the formatter:

path formatted sent raw
runAIAssistant targetPrompt (AIAssistant.ts:295) systemPrompt (:308)
Prompt prompt (:408) systemPrompt (:418)
ChunkedPrompt each chunk systemPrompt (:936) - and estimateTokenCount(systemPrompt) at :897 sizes the chunk budget on the raw string, which is the same admission
MacroChoiceEngine.executeAIAssistant formatter.formatFileContent on the prompt (:675) command.systemPrompt (:668)
Agent.buildSeedMessages prompt system (Agent.ts:271-277)

OpenAIRequest then puts it on the wire unchanged (:198, :246, :312). The
docs agree - AIAssistant.md:56 says the prompt template "is a Markdown note in
the prompt template folder, not raw prompt text" - and nothing anywhere claims
system prompts take tokens.

Only the UI disagreed. All three system-prompt fields ran a live
FormatDisplayFormatter and attached the {{ token autocomplete, so {{DATE}}
previewed as 2026-07-27 and then reached the model as eight literal characters.
That is worse than the defect #1565 reports: the preview did not merely fail to
warn, it asserted a substitution that will not happen. Mounting the good
preview implementation there would have made the lie better-typeset,
inline-diagnosed, and announced to screen readers.

So the affordance is deleted from those three, and the one field of the four that
really is a format field - the user script's type: "format" option, which the
script itself resolves with quickAddApi.format()
(EzImport.js:304)

  • gets the full builders' treatment.

Whether system prompts should be formattable is a real question and a bigger
one than these two issues: silent behaviour change for existing vaults, a
prompting {{VALUE:}} inside an agent loop, and a chunk budget computed before
the substitution. Filed as #1572. If it lands, the preview and the token
autocomplete come back with it. If you would rather ship that instead, say so
and I will revise
- the removal is one commit and reverts cleanly.

Before / after

#1568 - AI Assistant settings, "Default system prompt"

The shipped default prompt contains no tokens, so the preview was a
character-for-character duplicate of the textarea, at full body size - larger and
more prominent than the field it was previewing.

Before After

#1565 - AI Assistant macro command, "System prompt"

Before: {{DATE}} resolved on screen. It does not resolve at run time.

Before After

What replaces it, for authors who already have a token

One muted line, shown only when the value contains {{, so the token-free prose
prompt (the shipped default, and the seed for every new AI command) never sees
it. Muted rather than warning-coloured, matching .qa-ai-token-note directly
above it in the same modals: like that line it states how the field behaves, and
a {{ you want the model to read literally is a legitimate thing to type here.

#1565 - user script type: "format" option

The one field that keeps a preview. Before, the span was created before the
textarea, so it rendered above the field and read as its label - the exact
placement #1543 fixed in the builders. And since #1560 silenced the Notices, the
|case:pasc typo reported nothing at all: 0 Notices, 0 inline complaints,
preview rendering "Example Title" as if all were well.

Before After

What the user-script field gained

The Svelte FormatPreviewField is mounted rather than reimplemented: labelling,
placement, per-pass construction, the staleness token and the inline diagnostics
are ~80 lines of exactly the drift that produced this issue.
mountFormatPreview is a thin seam over the existing mountComponent, with a
$state-backed props object (mirroring createCommandListProps). It creates and
owns its host element, because mount() writes anchor comment nodes into
whatever it is given and this modal createEls straight onto contentEl.

Lifecycle is the trap. display() is re-entrant - the constructor calls it, and
migrateSecretSettings() calls it again from a voided promise that can land at
any time - and it empties contentEl. Emptying does not stop a Svelte component;
it only detaches it. destroyPreviews() therefore runs as the first statement of
display() and from a new onClose() (the class had none). The regression test
asserts on the tracked handle list, not on the DOM
: an orphan is detached along
with contentEl's children, so it vanishes from every query while still running.
The first version of that test passed against a build with the teardown removed.

Validation

Every claim below was reproduced and re-verified in this worktree's own isolated
Obsidian 1.13.0 vault, driving the real UI.

  • Before, on bf39e2ba: typing {{VALUE:title|case:pasc}} into the AI
    command's System prompt and into the user-script format option produced
    {"notices":0,"inlineIssues":0} in both. Total silence.
  • After: the user-script field reports
    {"notices":0,"label":"Preview: ","value":"Logged on 2026-07-27 for Example Title","issues":["Warning: Unsupported |case style \"pasc\" ... Supported styles: kebab, snake, camel, pascal, title, lower, upper, slug."]},
    with the preview positioned after the input.
  • The AI settings modal renders {"bareSpans":0}; typing {{ opens no
    suggestion container.
  • {{FOLDER}}/{{DATE}} in a user-script format option previewed
    Folder/Name/2026-07-27 mid-review and now previews /2026-07-27, matching
    quickAddApi.format().
  • A type: "format" option with no defaultValue opened the modal with an empty
    field (it crashed the constructor mid-review; see below).
  • Opening and closing the user-script modal three times leaves 0 preview rows.

Suite: 4106 passing. tsc, eslint, svelte-check clean.

Every new test was mutation-verified - reverted the fix, watched the test
fail, restored. That caught two tests that were passing for the wrong reason: the
teardown test above, and the four-path contract test, whose runAIAssistant case
did not exist until a reviewer pointed out that every existing test that names
runAIAssistant mocks it away.

Found by the review, fixed here

An adversarial review pass caught one regression this branch introduced:
initializeUserScriptSettings deliberately skips options with no defaultValue,
so addFormatInput's value: string arrives as undefined from a third-party
script - and FormatPreviewField calls value.trim() during mount. The throw
propagated out of the constructor, so new UserScriptSettingsModal(...).open()
never reached .open() and the gear button silently did nothing. Coerced at the
boundary, with both that case and a non-string stored value pinned.

Also in the diff

  • aria-describedby on the note while it is shown (and only while: an accessible
    description may include a directly-referenced hidden node).
  • aria-label on the four textareas that are appended to contentEl rather than
    their Setting's controlEl, so nothing associated them with the name above.
  • One paragraph in docs/.../AIAssistant.md, so the answer is findable before
    you type the token rather than only after.
  • addSlider into the shared obsidian test stub, replacing a local shim.

Considered and declined

Filed, not folded in

Summary by CodeRabbit

  • New Features

    • System prompts are now transmitted verbatim; format tokens like {{DATE}} stay literal—use the prompt template for dynamic values.
    • Added a “literal format” guidance note under system-prompt fields when token syntax is detected.
    • Added live preview for user-script “format” options, with correct handling when no folder path exists.
  • Bug Fixes

    • Improved preview mounting/teardown to prevent stale UI artifacts after refreshes and close/reopen cycles.
    • Enhanced accessibility by conditionally linking system-prompt guidance to the correct textarea.

#1565 asked for four modals to be brought under the inert-preview mechanism
#1560 built for the choice builders. Three of the four should not have a format
preview at all: the AI system prompt is sent to the model VERBATIM.

Only the prompt (or prompt template) goes through the formatter. Every path
agrees:

  - `runAIAssistant` formats `targetPrompt` (AIAssistant.ts:295) and passes
    `systemPrompt` raw to `OpenAIRequest` (:308).
  - `Prompt` (:408/:418) and `ChunkedPrompt` (:936) do the same - and the chunked
    path sizes its budget with `estimateTokenCount(systemPrompt)` on the raw
    string (:897), which is the same admission.
  - `MacroChoiceEngine.executeAIAssistant` hands `command.systemPrompt` straight
    through (:668) while its formatter callback wraps only the prompt (:675).
  - `Agent.buildSeedMessages` pushes `system` raw and formats `prompt`.
  - `OpenAIRequest` then puts it on the wire unchanged (:198/:246/:312).

The docs agree too: AIAssistant.md:56 says the prompt template "is a Markdown
note in the prompt template folder, not raw prompt text", and nothing anywhere
claims system prompts take tokens.

Only the UI disagreed. All three system-prompt fields ran a live
FormatDisplayFormatter and attached a `{{` token autocomplete, so `{{DATE}}`
previewed as 2026-07-27 and then reached the model as eight literal characters.
That is a worse defect than the one #1565 reports: the preview did not merely
fail to warn, it asserted a substitution that will not happen. Mounting the
GOOD preview implementation there would have made the lie better-typeset,
inline-diagnosed and announced to screen readers.

So the affordance goes instead. Removing it also closes #1568 outright: with the
shipped token-free default prompt, that preview was a character-for-character
duplicate of the textarea above it, at full body size, and read as the modal
printing the prompt twice. There is nothing to label if there is nothing to show.

What replaces it, for the authors who already have a token in there and would
otherwise get no signal at all: one muted line under the field, shown only when
the value contains `{{`, naming what happens and pointing at the prompt template
- the thing that IS formatted. Muted rather than warning-coloured, matching
`.qa-ai-token-note` directly above it in the same modals: like that line it
states how the field behaves, and a `{{` a user wants the model to read
literally is a legitimate thing to type here.

Whether system prompts SHOULD be formattable is a real question, and a bigger
one than these two issues - silent behaviour change for existing vaults, a
prompting `{{VALUE:}}` inside an agent loop, and a chunk budget computed before
the substitution. Filed as #1572. If it lands, the preview and the token
autocomplete come back with it and this note is deleted.

AIAssistant.systemPromptLiteral.test.ts pins the invariant in both directions -
the system prompt arrives byte-identical AND the formatter is never called with
it - so the person who changes the runtime is told to revisit the UI. Verified
to fail against a one-line mutation that formats it.

Drive-by: `addSlider` moves into the shared obsidian test stub, replacing the
local shim AIAssistantInfiniteCommandSettingsModal.test.ts carried for it.

Refs #1565
Closes #1568
The one field of the four #1565 names that really is a format field. A
`type: "format"` option is the script author's own declaration, and the
documented pattern is that the script resolves it itself with
`quickAddApi.format()` (docs/public/scripts/EzImport.js:304) - so unlike the AI
system prompt, previewing it is telling the truth.

It just wasn't telling it well. `addFormatInput` built its preview span BEFORE
the textarea, so it rendered above the field and read as its label - the exact
placement #1543 fixed in the builders. It memoized one FormatDisplayFormatter
for the modal's lifetime, whose resolved `variables` map short-circuits the next
pass. It committed `await format(value)` with no staleness token, so a slow pass
could overwrite a newer one. And it threw the collected diagnostics away, so
after #1560 silenced the Notices a malformed token reported nothing at all:
verified in an isolated Obsidian 1.13.0 vault before this change, typing
`{{VALUE:title|case:pasc}}` produced 0 Notices and 0 inline complaints while the
preview rendered "Example Title" as if all were well.

Rather than reimplement labelling, placement, per-pass construction, staleness
and diagnostics imperatively - ~80 lines of the drift that produced this issue -
the Svelte FormatPreviewField is mounted. `mountFormatPreview` is a thin seam
over the existing `mountComponent`, with a `$state`-backed props object (the
documented way to feed reactive props to an imperatively mounted Svelte 5
component, mirroring `createCommandListProps`). It creates and owns its host
element: `mount()` writes anchor comment nodes into whatever it is given, and
this modal `createEl`s straight onto `contentEl`, so a shared target would
interleave the anchors with later `new Setting(...)` rows.

Lifecycle is the trap here. `display()` is re-entrant - the constructor calls
it, and `migrateSecretSettings()` calls it again from a `void`ed promise that
can land at any time - and it empties `contentEl`. Emptying does not stop a
Svelte component; it just detaches it, so each re-render would orphan one per
format option, each holding a live `$effect` and a 500ms timer. `destroyPreviews()`
therefore runs as the first statement of `display()`, before `empty()`, and from
a new `onClose()` (the class had none).

The regression test for that asserts on the tracked handle list, not on the DOM:
an orphan is detached along with contentEl's children, so it vanishes from every
query while still running. The first version of this test asserted on
`.qa-preview-row` and passed against a build with the teardown removed.

Verified in an isolated Obsidian 1.13.0 vault: 0 Notices, "Preview: Logged on
2026-07-27 for Example Title" below the field, and the previously-silent warning
inline underneath naming the eight supported |case styles. Opening and closing
the modal three times leaves 0 preview rows behind.

Closes #1565
… default

Follow-ups from an adversarial review of the two commits above. The first is a
real regression the branch introduced; the rest harden what it shipped.

**A format option with no `defaultValue` crashed the modal.**
`initializeUserScriptSettings` deliberately skips options whose `defaultValue` is
undefined, so `addFormatInput`'s `value: string` arrives as `undefined` from a
third-party script - and `FormatPreviewField` calls `value.trim()` during mount.
The throw propagates out of `display()` and out of the constructor, so
`new UserScriptSettingsModal(...).open()` in CommandList never reaches `.open()`
and the gear button silently does nothing. Same for a non-string stored value,
e.g. a script that changed an option from `toggle` to `format` between versions.
The old code only degraded (a textarea reading the literal text "undefined"), so
this is new. Coerced at the boundary where untrusted script data enters typed
code, which fixes the "undefined" wart too. Both cases pinned, both verified to
fail without the coercion, and verified live: the modal now opens with an empty
field and no dangling preview row.

**`{{FOLDER}}` previewed a folder the runtime never produces.** The builders fall
back to a "Folder/Name" placeholder because a choice HAS a configured target
folder that no caller wires in yet. A user-script option has none at all, and
`Formatter.replaceMacrosInString` resolves `{{FOLDER}}` to `this.targetFolderPath
?? ""` there. `targetFolderPath` now accepts an explicit `null` meaning "this
host has no folder concept", distinct from `undefined` meaning "unknown", and
`mountFormatPreview` passes it. The builders pass neither, so their behaviour is
byte-identical. Verified live: `{{FOLDER}}/{{DATE}}` previewed
`Folder/Name/2026-07-27`, now previews `/2026-07-27`.

**The contract test covered two of the four paths its own comment cites.** Every
existing test that names `runAIAssistant` mocks it away, so the macro AI
command's runtime - the path behind the modal this branch strips - had no
assertion on its `systemPrompt` argument anywhere in the suite. `runAIAssistant`
and `Agent.buildSeedMessages` are now driven for real. All four cases verified to
fail against a one-line mutation that formats the system prompt in that path.

**A11y.** The note is now referenced by `aria-describedby` while it is shown (and
only while, since an accessible description may include a directly-referenced
hidden node). The four textareas that are appended to `contentEl` rather than
their Setting's `controlEl` - so nothing associates them with the name above -
get an explicit `aria-label`.

**Copy.** "Use the prompt template for text that needs tokens" pointed at a
control the global AI settings modal does not show; it now says which input IS
formatted. The same sentence is added to the AI Assistant docs, so the answer is
findable before you type the token rather than only after.

**Polish.** The preview host takes a class and a negative top margin: the
textareas carry `margin-bottom: 1em` for the gap to the NEXT setting, which left
the preview row floating midway between the field it describes and the one below.

Also: coverage for the AI modals' `reload()` path (the note must be rebuilt with
its field, not hoisted), for `mountFormatPreview`'s destroy-then-setValue path,
and a dead `beforeAll` removed - it justified itself with a false claim about the
shared obsidian stub, which does define `onClose`.

Refs #1565, #1568
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb70a908-c8b1-4d15-8ed5-c55e5d2ea15c

📥 Commits

Reviewing files that changed from the base of the PR and between 6364c0f and cfad90e.

📒 Files selected for processing (2)
  • src/gui/MacroGUIs/UserScriptSettingsModal.formatPreview.test.ts
  • src/gui/MacroGUIs/UserScriptSettingsModal.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/gui/MacroGUIs/UserScriptSettingsModal.formatPreview.test.ts
  • src/gui/MacroGUIs/UserScriptSettingsModal.ts

📝 Walkthrough

Walkthrough

System prompts are now transmitted verbatim while prompt templates remain formatted, with literal guidance added to three AI settings modals. User-script format previews now use mounted Svelte components with null-folder handling and explicit lifecycle cleanup.

Changes

System Prompt Literal Treatment

Layer / File(s) Summary
Verbatim system prompt transmission
src/ai/AIAssistant.systemPromptLiteral.test.ts
Tests cover literal system prompts across assistant execution paths while prompt templates remain formatted.
Literal system prompt note
src/gui/ai/systemPromptLiteralNote.ts, src/gui/ai/systemPromptLiteralNote.test.ts, src/styles.css
Adds token-triggered explanatory UI with conditional accessibility wiring and styling.
AI settings modal wiring and documentation
src/gui/AIAssistantSettingsModal.ts, src/gui/MacroGUIs/AIAssistantCommandSettingsModal.ts, src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.ts, src/gui/ai/systemPromptFields.test.ts, docs/src/content/docs/docs/AIAssistant.md
Replaces system-prompt formatting previews and suggesters with the literal note, retaining token counts and documenting verbatim transmission.

Format Preview Rework for UserScriptSettingsModal

Layer / File(s) Summary
Preview component contract and mount wrapper
src/gui/ChoiceBuilder/components/FormatPreviewField.svelte, src/gui/ChoiceBuilder/components/mountFormatPreview.svelte.ts
Supports null folder paths and adds an imperative preview handle with update and teardown methods.
UserScriptSettingsModal preview integration
src/gui/MacroGUIs/UserScriptSettingsModal.ts, src/styles.css
Mounts live previews, updates them on edits, and destroys them on rerender or close.
Format preview behavior tests
src/gui/MacroGUIs/UserScriptSettingsModal.formatPreview.test.ts
Covers rendering, stale results, diagnostics, null folders, value handling, persistence, and teardown.
Slider test support cleanup
tests/obsidian-stub.ts, src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.test.ts
Adds reusable slider stubs and removes the local test shim.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels: released

Poem

A rabbit nudged tokens aside,
“Send prompts as typed,” it cried.
Previews now renew,
And old hosts bid adieu.
Clean little hops, side by side. 🐇

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning FAIL: [#1565, #1568] are not met; the AI system-prompt fields no longer render the requested labeled preview/inline diagnostics. Restore the requested preview surfaces: labeled system-prompt preview rows with inline diagnostics for #1565/#1568, or revise the issue scope to match the literal-note design.
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change to stop system-prompt preview substitution.
Out of Scope Changes check ✅ Passed All changes support the system-prompt/format-preview fix, plus the tests, docs, styles, and stubs needed to validate it.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1565-modal-inert-previews

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: cfad90e
Status: ✅  Deploy successful!
Preview URL: https://ce24d7cf.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1565-modal-inert-p.quickadd.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6364c0fb54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/gui/MacroGUIs/UserScriptSettingsModal.ts Outdated
Caught by Codex on #1581. The previous commit coerced a non-string stored value
to "" so `FormatPreviewField`'s `value.trim()` could not throw out of the
constructor. But `resolveUserScriptSettings` still forwards the original to the
script, so a `toggle`-to-`format` type change across script versions left the
field claiming the setting was empty while execution received `true`.

Absent stays absent - `initializeUserScriptSettings` deliberately leaves an
option with no `defaultValue` unset so the script can apply its own, and
printing "undefined" would be its own lie. Anything present is stringified,
which also makes this method consistent with its four siblings: they all render
`value as string` unchanged.

Persisting the normalization instead was rejected: for the absent case it would
overwrite the deliberate undefined, and for the present case it means a disk
write from a render pass.

Refs #1565
@chhoumann

Copy link
Copy Markdown
Owner Author

Linked Issues check — FAIL: #1565, #1568 are not met; the AI system-prompt fields no longer render the requested labeled preview/inline diagnostics.

That is the deliberate divergence, and it is the first section of the PR description rather than an oversight — flagging it here so a human reader does not have to reconcile the bot's verdict with the diff.

Both issues describe the symptom ("the preview is noisy / is an unlabelled duplicate") and propose the fix that follows from assuming those fields are format fields. They are not: systemPrompt never touches a formatter on any of the five runtime paths, so the preview the issues want improved was asserting a substitution that does not happen. Rendering it labelled, inline-diagnosed and announced to screen readers would have made it a better-presented falsehood.

@chhoumann
chhoumann merged commit bf40ec5 into master Jul 27, 2026
13 checks passed
@chhoumann
chhoumann deleted the chhoumann/1565-modal-inert-previews branch July 27, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant