Custom views
BetaGive your plugin its own screen. A view lets a local plugin ship its own user interface. Wrengle renders it as a tab in the editor area — it can sit in a split next to your notes — inside a sandboxed iframe.
Views are part of the local developer preview. The bridge protocol below may change between releases.
Declare a view
Add a [[views]] block to your plugin.toml:
[[views]]
id = "plugin.example.my-view"
label = "My View"
entry = "ui/index.html"
accepts_selection = true
selection_label = "Send to My View"| Field | Required | Description |
|---|---|---|
id | Yes | Unique within the manifest across actions and views. Must start with the plugin id followed by a dot. Lowercase ASCII letters, digits, dots, and hyphens only; no ... |
label | Yes | Display name shown in the command palette and Actions menu. |
entry | Yes | Relative path to the HTML file inside the plugin folder. The file must exist at reload time. |
accepts_selection | No | If true, an item appears in the editor Actions menu to send selected text to this view. Defaults to false. |
selection_label | No | Label for the Actions menu item. Defaults to Send selection to <label>. Only meaningful when accepts_selection = true. |
A plugin can be view-only: no [runtime], no plugin.wasm, and no build step
are required. The manifest only needs at least one action or one view to be
valid.
One self-contained HTML file
Your entry file must be one self-contained HTML file. Wrengle validates and
captures it during plugin reload. While developing, edit the file, click
Reload, then reopen the tab to use the new immutable snapshot.
Constraints:
- All CSS and JavaScript must be inlined in the single file. No external
stylesheets, no
srcattributes pointing to other files. - If you use a build tool, configure it to output a single bundled file.
- The file must be no larger than 5 MB.
The bridge protocol
Wrengle installs a host-owned, per-document message channel before plugin code
runs. Incoming messages are also delivered as message events for
compatibility. Except for the declared state helpers on Builder-created apps,
the iframe has no access to Wrengle's internal APIs.
| Direction | Message type | Payload | When |
|---|---|---|---|
| host → view | wrengle:init | { theme: "light" | "dark", themeTokens: Record<string, string> } | Sent once after the host-owned channel is ready. |
| host → view | wrengle:theme | { theme: "light" | "dark", themeTokens: Record<string, string> } | Sent when the user changes the app theme or active theme tokens. |
| host → view | wrengle:selection | { text: string } | Sent when the user invokes the view's selection action. |
Wrengle injects the active theme variables into the view before first paint and
sends the same values as themeTokens whenever the active theme changes.
themeTokens keys are CSS custom property names with the leading --, such as
--background, --foreground, --border, --primary, and
--primary-foreground. Use those variables and the rest of the Wrengle theme
tokens instead of maintaining a separate plugin color palette.
Add this block inside a script element in your HTML to implement the bridge:
function applyWrengleTheme(message) {
if (message.theme) {
document.documentElement.dataset.theme = message.theme;
}
if (!message.themeTokens || typeof message.themeTokens !== "object") return;
for (const [name, value] of Object.entries(message.themeTokens)) {
if (
/^--[A-Za-z0-9_-]+$/.test(name) &&
typeof value === "string" &&
value.length > 0
) {
document.documentElement.style.setProperty(name, value);
}
}
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (!msg || typeof msg !== "object") return;
if (msg.type === "wrengle:init" || msg.type === "wrengle:theme") {
applyWrengleTheme(msg);
} else if (msg.type === "wrengle:selection") {
// msg.text is the text the user selected in the editor
}
});Wrengle performs the ready handshake automatically. Direct parent-window messages are ignored. Navigating the iframe revokes the initial document's channel, so its authority cannot follow the stable iframe window.
Builder-created offline state helpers
Plugin Builder currently creates static custom views and offline feature apps.
An offline feature app's draft declares the state helpers it expects to use,
and Wrengle checks the generated code against that declaration before you
apply it. Installed app state is plugin-scoped local JSON state that Wrengle
manages. Manual local plugin views cannot use these generated window.wrengle
helpers yet.
Plugin Builder injects these helpers into offline feature apps so generated code never needs direct parent-window access:
| Helper | Availability | Behavior |
|---|---|---|
window.wrengle.state.get(key) | Builder-created offline feature apps | Reads plugin-scoped local JSON state. |
window.wrengle.state.set(key, value) | Builder-created offline feature apps | Writes plugin-scoped local JSON state. |
window.wrengle.state.delete(key) | Builder-created offline feature apps | Deletes plugin-scoped local JSON state. |
window.wrengle.state.list(options) | Builder-created offline feature apps | Lists only the keys this app declared, with their values. |
window.wrengle.state.clear() | Builder-created offline feature apps | Deletes only the values the app itself saved. |
A draft declares its expected state keys, and Wrengle checks the generated
code against those keys before you apply it. Once installed, an app's calls
stay scoped to its own local JSON state and limited to the keys it declared.
Builder-created offline apps can use state.list and state.clear, but only over the keys they declared and the values they saved.
Wrengle's own reserved keys are never listed and never cleared, so an app can
reset the data it saved without touching anything Wrengle keeps for it.
Clearing removes only the app's saved data; its plugin files, prompt history,
and conversations stay put. You can still reset an app's state yourself from
Plugin Builder.
A Builder-created app reaches your workspace only through the reads and
write proposals its generated manifest declares. An app that declares none
cannot read or write your notes, projects, blocks, meetings, workflows,
files, or settings. No Builder-created app can use external network access,
secrets, shell commands, generated WASM actions, or direct access to the
desktop process outside the window.wrengle bridge.
A prompt is refused up front only when it needs external network access, credentials, secrets, or an unsupported platform. A prompt that needs your notes is admitted: Plugin Builder designs against the shape of your workspace, asks before reading note content, and declares the reads the app will need. An app-mediated write is always a proposal — nothing changes until you approve it in Wrengle.
Workspace listing helper
A plugin view that holds the "Note read" permission can also ask Wrengle for a page of note paths and titles:
| Helper | Availability | Behavior |
|---|---|---|
window.wrengle.listNotes(options) | Plugin views granted note read | Lists note paths and titles under an optional folder prefix. |
window.wrengle.searchNotes(options) | Plugin views granted note read | Finds notes by relevance and returns bounded titles and excerpts. |
options accepts prefix (a folder such as "projects/"), limit (at most
500 entries, default 100), and cursor (the nextCursor from a previous page).
The result carries entries, nextCursor, and totalMatched. Listing returns
paths and titles only; it never returns note text.
searchNotes takes query (up to 4096 characters) and k (at most 20 hits,
default 8). Each hit carries path, title, a bounded snippet, and a
relevance score used only for ordering. Search returns excerpts, never a
whole note.
Workspace read and write requests
A plugin view holding the matching grant can ask Wrengle to read a single note or its blocks, or to propose a write. Note and block paths must be vault-relative Markdown files:
| Helper | Availability | Behavior |
|---|---|---|
window.wrengle.readNote(path) | Plugin views granted note read | Reads one note's raw Markdown, anywhere in your vault. |
window.wrengle.readBlocks(options) | Plugin views granted block read | Reads one note's structured block snapshot. |
window.wrengle.writeNote(request) | Plugin views granted note write | Proposes replacing a note's whole Markdown content. |
window.wrengle.writeNotes(request) | Plugin views granted note write | Proposes one batch covering changes to 1–50 notes. Block-edit items also require block write. |
window.wrengle.writeBlocks(request) | Plugin views granted block write | Proposes structured block edits to one note. |
window.wrengle.insertNote(request) | Plugin views granted note write | Proposes inserting Markdown before, after, or in place of one block. |
window.wrengle.createNote(request) | Plugin views granted note write | Proposes a new note at a path that does not exist yet. |
window.wrengle.moveNote(request) | Plugin views granted note write | Proposes moving a note to a path that does not exist yet. |
window.wrengle.deleteNote(request) | Plugin views granted note write | Proposes deleting a note. |
readNote and readBlocks accept any Markdown note path in the vault, the
same widened rule as the listing helpers above. writeNote, writeBlocks,
and insertNote still require the note to sit under notes/, projects/,
or people/. Every write request creates a proposal you approve or decline,
the same approval flow a plugin action uses; nothing is written until you
approve it, and approving re-checks the note before saving so a stale write is
reported as a conflict instead of silently overwriting your changes.
readBlocks({ path, source: "openDocumentPreferred" }) uses the latest
native-admitted serializable editor projection when it is still based on the
unchanged saved note, then falls back to disk when no safe live projection
exists. Publication is asynchronous, so a just-typed edit can appear on a
subsequent read rather than the first call made in the same UI turn.
source: "diskSnapshot" always reads the saved file. The live result's
blocks, content hash, and revision are one snapshot; echo its hash and revision
into a single-note writeNote, writeBlocks, or insertNote request so
approval conflicts if the editor or saved-file baseline changes. Live
revisions are not supported for move, delete, or batch writes. Close a source
note before approving its move or deletion.
createNote, moveNote, and deleteNote follow the same write-path rule as
writeNote: the note must sit under notes/, projects/, or people/.
createNote and the destination of moveNote must target a path that does
not exist yet, and moveNote also checks its source path.
writeNotes({ summary, items }) creates one approval proposal covering 1–50
items. Each item has an op of write, create, move, delete, insert,
or blocks, followed by the same fields used by that single-note helper. The
batch itself requires note write; a blocks item additionally requires block
write. Approval checks every item's preconditions before changing any note,
so a detected conflict leaves the whole batch unapplied. The result has one
proposalId plus an items result for every requested note.
Where views appear
- Command palette: an "Open <label>" entry appears for every loaded view, with the plugin name shown as attribution. Use ⌘K (macOS) or Ctrl+K to open the palette.
- Editor Actions menu: when
accepts_selection = true, an item labelledselection_labelappears in the floating toolbar's Actions menu when text is selected in a note.
The palette command always opens a new tab, so you can run several instances of the same view side by side. The selection action instead reuses the most recently used open tab of that view — it focuses that tab and sends the text there, opening a new tab only when none is open.
For a runnable reference, follow the
JSON Compare tutorial, which loads
examples/plugins/json-compare with no build step.
Sandbox and trust
The iframe is created with sandbox="allow-scripts" and no allow-same-origin.
This means:
- Except for the declared state helpers on Builder-created apps, the view has no access to the app, your vault, or Wrengle's APIs.
- The view cannot read cookies, localStorage, or sessionStorage from the app origin.
- The view cannot call the host app's IPC or access the parent DOM.
Every view receives a mandatory host-owned offline Content Security Policy.
Wrengle applies the policy to every view. Direct network requests, WebSockets,
WebRTC, navigation, forms, nested frames, and external scripts, styles, fonts,
and media are blocked. Embedded WebKit and WebView2 do not implement every
CSP3 rule uniformly, so Wrengle also replaces peer-network constructors in its
host-owned bootstrap before plugin code runs. The httpHosts capability gates
only Wrengle host-bridge HTTP calls made by WASM actions; it never relaxes a
view's offline policy.
View HTML, its exact loaded code identity, and the offline policy come from the same immutable snapshot captured at reload. Changing the manifest, WASM, or view bytes makes positive grants pending, so you must approve the changed code again. Plugin state and keychain values remain in their stable plugin-and-path namespace. Load only local plugin code you trust.
Troubleshooting
| Symptom | What to check |
|---|---|
| View missing from palette | Open Settings → Plugins and click Reload. Check Diagnostics for manifest errors. |
| Blank tab | Verify the entry path is correct relative to the plugin folder and that the file is under 5 MB. |
| Selection item missing | Set accepts_selection = true in your [[views]] block and reload. |
| Stale UI while developing | Click Reload, then reopen the tab so Wrengle captures and uses the changed HTML. |