The Kalem Book GitHub

This Book describes Kalem's main branch, which may be ahead of the latest release.

Plugins

Plugins are WebAssembly components, and language plugins, which are declarative (Language plugins). Two kinds of component run today: viewers of files that are not text, Kalem's own viewers of pictures, PDF files and workbooks among them, and extension plugins, which add commands, keys, event handlers, edits of the document, settings and panels. kalem plugin finds, installs, builds and develops them. The design is decided (D25, D28, D29) and written out in section 11 of the design document. Completers (API 0.2.11) and layers over a mode (0.2.10) are there too; what it names beyond these (modes, block renderers, link types, export back-ends as plugins) is planned, each listed with its task in crates/kalem-plugin/coverage.toml.

1. The model

  • A plugin is a WebAssembly component, written in Rust against the kalem-plugin crate, which is generated from the API's definition in WIT. It sees only what the API grants: no file system, network, clock or threads of its own; its own memory, 64 MB by default; a time budget, 100 ms for a synchronous call (a viewer's are 1 GB and 10 s, below). A plugin error never crashes Kalem, and a viewer that stops three times is turned off until it is updated.
  • One contract. The trait a viewer implements is the one Kalem's own viewers implement, so a plugin's unit tests run natively in its crate while Kalem runs it as a component, its own viewers included. Kalem ships no runtime for other languages and no scripting engine.
  • Permissions are declared in the manifest, approved when the plugin is installed and recorded in plugins.toml: fs:read:workspace, fs:write:workspace, fs:read:all, net:fetch:DOMAIN, and, with a separate warning, subprocess (a language plugin's servers) and subprocess:PROGRAM (one program a component runs).
  • Out of process for what cannot be a component: language servers talk JSON-RPC over standard input and output, and the programs a component runs (process) are run with their arguments, their output read back. From phase 4, a power user may compile plugins into their own Kalem binary, with native speed and no sandbox, by their own choice.

2. The API

The API is defined in WIT, in crates/kalem-plugin/wit/ (D6): the one source both sides are generated from. A plugin builds against the kalem-plugin crate (wit-bindgen); Kalem's host generates its side from the same files (wasmtime). It holds eight worlds, three kinds:

  • document-viewer (§11.13): a plugin opening files that are not text. It exports viewer: describe, detect and open, and a document resource with the viewers' contract: structure, size, render (a bitmap the host paints), text, text-rects, text-at, info, search, links, and the editor half (edits, apply, modified, save); password, for a file that needs one; formats, the kinds it saves as and new files; and annotations, comments and tracked changes. It imports files, the file resource of the one file the host opened for it, with its name, its size and the bytes the plugin asks for, never a path; clock; and diagnostics.
  • spreadsheet-viewer: the same, and the grid interface of sheets of cells (below); flow-viewer, the same with the flow interfaces of flowing text (the Word plugin's); and full-viewer, both.
  • extension: a plugin that adds to the editor rather than opening files. It imports kalem, editor, ui, settings, diagnostics, clock and clock-2, layers, documents, styled-documents, decorations, problems, graph-view, canvas-view, and fs (with fs-2), net and process when its permissions grant them, and exports plugin (below). extension-layer, extension-completer, extension-layer-2, extension-layer-3 and extension-formatter are the same world exporting more: a layer, then a completer, then a layer's lines under lines, then lines that show a file's text, then a formatter, each with those before it.

crates/kalem-plugin/coverage.toml lists every extension point of the design (§11.10 to §11.13) with the WIT interface that defines it or the open task that will; a test keeps it in step with the design document and the task list.

3. The host

kalem-script runs components in wasmtime with its Cranelift compiler, the engine chosen by measurement (D28):

  • A component is compiled the first time it loads and kept in the state directory, named by its hash and the engine's version and settings; later starts read it in a fraction of a millisecond. A file another engine wrote, or a damaged one, is compiled again.
  • A plugin reaches only what Kalem grants it. Its imports are resolved against what the host defines for it and nothing else; one asking for more is refused, and the refusal names the import.
  • Each call has a time budget, 100 ms by default, counted by a clock that ticks every 10 ms; a plugin past it is stopped. Each instance has a memory limit, 64 MB by default; a plugin asking for more gets an allocation failure, and Kalem tells the two apart from the plugin's own failures.
  • A loaded plugin is shared by threads, each with an instance of its own, so parsers, renderers and completers run in parallel.
  • Plugins are built for wasm32-unknown-unknown and wrapped as components, so they import nothing the API does not name; built for wasm32-wasip2 a Rust component imports fifteen WASI interfaces it never uses.

A plugin is a folder with a manifest, plugin.json (its ID, the component, the API version, activation events, permissions, limits, and the menus, buttons, layers, completers and settings it contributes; its commands and keys it registers when it starts), installed under plugins/ID/ in the settings directory and loaded on its activation events. Command IDs follow the registry's rule, dotted parts of letters and digits each starting with a lower case letter, so an ID has no hyphens. Plugin settings go in settings.toml under [plugins."ID"], and bindings for plugin commands in keymap.json like any other.

4. What a plugin can add

Commands and key bindings; link types; renderers for special blocks and source languages; decorations; completers and hover text; input rules; highlighters and document modes; views (a board, a timeline); panels, as a JSON widget tree both editors draw; diagnostics with fixes; export back-ends and filters; importers and paste handlers; Babel executors; table functions; dynamic blocks; agenda views and capture templates; command-line subcommands; themes. Each appears in both editors and, where it makes sense, on the command line (kalem run, kalem check, kalem export --to NAME). Plugins cannot change the parser's grammar, draw outside the widget tree and SVG, or hold the editor's thread beyond the budget.

A mode is the largest of these: detect, parse (a flat tree of kinds and byte ranges, incremental), grid for table-like formats, edit for Enter, Tab and toggles, the outline, formatting, completion and diagnostics hooks, and an optional export. It returns ranges, never text, so it cannot break the byte-exact round trip; it has two levels, declarative (a syntax definition and a mapping to the kinds, no code) and programmatic (a parser in Rust); and a conformance suite every mode runs. The completer and mode contracts as they exist today are in Completers, modes and highlighters.

5. Inspection and the repository

Kalem has no REPL. Of a live runtime it has one part: a plugin installed, updated or removed while Kalem runs is taken up without a restart, which kalem plugin dev uses. An inspection panel (the loaded plugins with their permissions, budgets and errors, the command registry, timings) and a local debug socket, off by default, are planned (group 3.2).

Plugins beyond the core live in getkalem/plugins: one Cargo workspace with a crate per plugin (the viewers of pictures, PDF files, workbooks and Word documents, git, note graphs, plain text accounting, and the Elixir and Rust language plugins today), a template that kalem plugin new copies, and an index Kalem reads as a static file. Components are never committed: CI builds every plugin against the Kalem revision its Cargo.lock pins, and a tag (pdf-viewer-v0.0.2) builds, signs and publishes its .wasm. kalem plugin install, list and remove work for declarative plugins and components alike: a component released in the index is downloaded, its SHA-256 checked against the index, and its manifest read from its folder at the release's tag, which must name the same plugin and version; for one not released yet Kalem says to build it with kalem plugin build and install that folder. Two commands start and build a component:

  • kalem plugin new NAME starts a plugin. Inside a checkout of getkalem/plugins it copies template/ to plugins/NAME and renames it; anywhere else it writes a crate of its own in NAME/ that builds alone.
  • kalem plugin build [DIR] runs Cargo for wasm32-unknown-unknown (rustup target add wasm32-unknown-unknown once) and wraps the module as the component main names in plugin.json, with wasm-tools (cargo install --locked wasm-tools once; Kalem calls the tool rather than carrying its 2.3 MB). It prints what the component imports and exports: a plugin imports only what its WIT world names.
  • kalem plugin dev [DIR] builds the plugin with its functions' names kept, installs it, and builds and installs it again whenever its sources change, until stopped. A Kalem running opens files with the new build from then on (one started later registers the plugin), and a trap in its log reads as a backtrace of the plugin's functions. The plugin's own unit tests run natively in its crate, with cargo test.

kalem plugin verify is planned.

6. Viewers as components

A viewer written against the Rust contract of kalem-viewer builds as a component with one line, kalem_plugin::export_viewer_of!(PdfViewer) under #[cfg(target_arch = "wasm32")]: kalem-plugin's adapter writes the document-viewer world over the trait, and the plugin reads the file the host gives it through the same FileHandle API, piece by piece. The PDF and image viewers of getkalem/plugins build this way.

kalem plugin install DIR installs a viewer whose component is built (kalem plugin build DIR); it says which files it opens before asking. At start Kalem registers the installed component viewers (in a build of one's own with viewers built in, in place of the built-in one of the same name), and a thread compiles them, or reads them from the cache in the state directory, so the first file they open does not wait. Each document a component opens gets an instance of its own. One that fails, runs past its time or out of its memory stops: the editor closes its document at once and says which plugin stopped and why, and that its unsaved changes, which nothing can save any more, are lost; the editor itself goes on, and the log keeps the plugin's version and what went wrong. A plugin that stops three times is turned off until it is updated, and its files go at once to what opens them next: a newer copy installed, else a copy built into Kalem when a build of one's own has one, else nothing, and the file is offered to the system's application. kalem plugin list says so, and kalem plugin enable ID turns it on again. Kalem need not be started again for any of this: a viewer installed, updated or removed while Kalem runs, from the plugin list or with kalem plugin install in a terminal, opens its files (or gives them back) within a second or two, and the status bar says which copy opens them now. A viewer's limits are 1 GB and 10 s a call, more than §11.6's defaults, since a viewer holds a whole file; a manifest may set others, limits.memory_mb and limits.time_ms.

The viewers of pictures, PDF files and workbooks are such components, released by getkalem/plugins and installed like any other plugin: none is built into Kalem. A file that is not text and that no installed plugin opens names the released plugins of the indexes that serve it, to install, after which the file opens; and the system's application (Find a Plugin for the File, plugin.forFile).

7. When a plugin serves a file

Every plugin, whatever its kind, says in one declaration when it serves a file, and Kalem tests that declaration alike for every kind of plugin (kalem_core::applies), both to hand a file to an installed plugin and to name a plugin of the index for a file. So the plugin named for a file is the one that serves it once installed, and a plugin serves the files it declares and no other.

The declaration is a list of rules, and a plugin serves a file one of its rules matches. A rule says what the file is, by any one of the things it lists: its extension (.xlsx, .html.eex, the longest counting), its whole name (mix.lock), its first bytes (magic, pairs of hexadecimal digits, ?? for any byte: "25 50 44 46 2D" is %PDF-), the interpreter of its #! line (elixir); and where it is, by any one of its markers, files or folders that must be in the file's folder or one above it (logseq/config.edn for a Logseq graph, .obsidian for an Obsidian vault). A rule that lists both asks both: a site's plugin may serve .md files under a folder holding hugo.toml, and no other Markdown file. The rules are what the manifest already says, and what it adds:

  • the extensions a viewer opens;
  • the extensions, whole names (filenames) and interpreters (shebangs) of the languages it serves;
  • the markers of each of its layers;
  • its own applies, a rule or a list of them, with extensions, filenames, magic, shebangs and markers: {"magic": ["25 50 44 46 2D"]}, {"markers": [".git"]}.

A viewer's rules hold for files that are not text only: Kalem opens a text file in a mode, even one a viewer could show (an SVG drawing is XML). The index copies these parts of each manifest as they are (tools/build-index.py), and Kalem reads them from the index and from an installed manifest by the same code. Where several plugins serve a file, the surest match is used: a whole name, then an extension (a longer one before a shorter), then first bytes, which formats share (every Office file is a ZIP archive), then a #! line, then a marker alone; a rule whose marker is found as well before one with none; the first installed (the later, for languages) among equals. Kalem uses the declaration everywhere:

  • a file that is not text opens in the installed viewer that serves it; the viewer's detect is not asked;
  • a text file's language plugin is the one that serves it, the language then chosen among its own (by the whole name, the extension, the #! line; its first when the plugin serves the file by a marker);
  • an extension plugin whose manifest names no activation starts with the first file opened that it serves (onFile), or with Kalem when it declares none;
  • a file Kalem cannot open (not text, no installed viewer serves it) names the released viewers of the indexes that serve it, to install, then the file opened, and the system's application;
  • a text file it opens (a source file, a Markdown page of a graph) names the first released plugin that serves it and is not installed, said once in the status bar and never again for that plugin, which Install Suggested Plugin (plugin.installSuggested) installs; plugins.suggest = false says none. The index is read when Kalem starts, once a day at most, and kept in the state folder (plugin-index.json), so a file opened offline is named too.

The feature components of a build of one's own still builds the released ones in, pinned by crates/kalem-components/components.toml by their release and SHA-256, downloaded (or read from the folder KALEM_COMPONENTS_DIR names), checked and kept compressed, inflated when first used; the tests take them from there. kalem plugin list marks such a copy built in, and an installed copy of one is used only when it is newer. Their manifests set their limits as an installed one's do. Through a component a viewer took one to two times the time of the same code built into Kalem natively, as it was until 2026-10-05; a test measures each against a ceiling; a workbook needs about 256 MB inside the component for a million cells.

8. Viewers of flowing text

A word processing document, an e-book, a web page or an e-mail is made of paragraphs that flow: no page or cell of their own until something lays them out. A viewer of such files exports the flow interface (API 0.2.7, kalem-plugin's feature flow, the flow-viewer world), and Kalem lays the paragraphs out itself, in both editors, as it lays out an Org document: the plugin never draws. The interface says what a unit holds in the words every such format has, and nothing of one format: paragraphs with a role (body, title, heading, list item, quote, code, caption), a level, a style's name for the user, a list label, an alignment, indents and spacing; runs of text with their marks (bold, italic, underline, strikethrough, raised or lowered, capitals, hidden, a color, a highlight, a size, a typeface), a link, and the annotations they are in; tables whose rows hold cells of paragraphs; asides (headers, footers, notes, text boxes) and rules (breaks). A unit's flow is a list of items, the starts and ends of tables and asides among the paragraphs, since WIT has no type that holds itself, and the host fetches it a range at a time, so that a book is never sent whole. A unit is a flow when flow.layout answers one, as a sheet is a grid when grid.layout does; viewer's unit kinds are frozen.

Kalem shows a flow unit as a document of the editor, of the mode flow: its text is the paragraphs' edit texts, a paragraph a line (a table's row of one-paragraph cells a line, its cells apart by tabs), so that the cursor, selection, search, Vim's motions and both frontends' drawing work on it unchanged, and each line shows the look the plugin gave its runs, sizes in proportion to the body text's. An edit of that text is not applied to it: Kalem turns it into the plugin's edits (replace for typing and deleting in a paragraph, split for a line feed typed, join for one deleted, delete across paragraphs), one step of the plugin's history, and reads the paragraphs again. The plugin writes them in its format's own vocabulary or refuses them, saying why (deleted text of a tracked change, a field's result, a note's mark), and the editor shows the reason. Undo and redo are the plugin's; saving writes the bytes the plugin gives. After an edit Kalem asks what changed since the version it holds (flow-3's changes, API 0.2.9): the items from one on, so many of them then, are so many now, and the paragraphs after them are numbered on by so much; it fetches those items and lays out their lines again, and nothing else, so that a keystroke in a document of twenty thousand paragraphs takes a millisecond or two. A plugin without it, or one that cannot tell, has its flow read whole. A paragraph's edit text counts a tab, a line break (U+000B), a page break (U+000C), a column break (U+000E) and an object (U+FFFC, a picture, a note's mark) as one character each. A run that is a picture names it and gives its size in points; Kalem asks the plugin for its pixels (render-picture, no more than it shows them, in powers of two so that a zoom seldom asks again), keeps them, and draws the picture in its line, in proportion to the body text as in the document; one the plugin cannot draw shows its text.

The look of the text is changed as a word processor changes it, through set-marks (bold, italic, underline, strikethrough, raised or lowered, a color, a highlight, a size, a typeface, or back to the style's look) on a range of places, and set-style on a range of paragraphs, from the styles styles offers. The commands of the Format category do it on the selection, or the word at the cursor when nothing is selected: Bold (Ctrl+B), Italic (Ctrl+I) and Underline (Ctrl+U) turn a mark on, or off where the whole selection has it, as Strike Through, Superscript and Subscript do; Font, Font Size, Text Color, Highlight Color and Paragraph Style offer a list (the document's typefaces and common ones; common sizes; a word processor's colors; the document's styles, the default first, then the title's and the headings') and take a value typed; Clear Formatting (Ctrl+Space) gives the text back its style's look. They are in the Format menu, the Review menu has the annotations' commands, and in the graphical editor the toolbar shows the paragraph's style, the typeface and the size at the cursor, each opening its list, and the marks, pressed where the text there has them.

Paragraphs' look and lists are flow-2's set-paragraphs (API 0.2.8), on the selection's paragraphs or the cursor's: Align Left (Ctrl+L), Center (Ctrl+E), Align Right (Ctrl+R) and Justify (Ctrl+J); Bullets (Ctrl+Shift+L) and Numbering, which make the paragraphs a list's items or, when all are already, take them out of it, and Numbering Style (1., a., A., i., I.); Increase and Decrease Indent, a list item a level deeper or shallower and another paragraph's indent a half-inch step, each paragraph its own and all one step; Line Spacing, Space Before and Space After, from a list or typed; Clear Paragraph Formatting. The toolbar has the alignments, the lists, the indents and the line spacing, pressed as the cursor's paragraph has them.

What people wrote about a document rather than in it is the annotations interface, apart from flow because other viewers have it too, and every viewer world exports it (a viewer without any answers none):

  • A comment is a remark someone made on part of the document, with its author, date and text; it may be answered (an annotation whose parent is the comment) and marked resolved. Word's and OpenDocument's comments, a PDF's notes and highlights, a workbook's notes and threaded comments, a slide's comments are comments.
  • A revision is a tracked change: an insertion, a deletion, a change of formatting, a move from or to a place, with its author and date; it is accepted (it becomes the document's) or rejected (the document is as before it). Word's and OpenDocument's tracked changes are revisions.

An annotation is anchored to what it is about: text of a flow (from a paragraph's place to another's), bytes of a page's text, a cell of a grid, an area of a unit, or a whole unit; a run of a flow names the annotations it is in by ID, so the host draws them without knowing the format. The host shows inserted text underlined and deleted text struck through in their colors, and text a comment is on highlighted; the status bar says the comment or the change at the cursor. The commands of the Review category (and menu) add, answer, edit, resolve and delete comments, go to the next and previous comment or change, accept and reject a change or all of them, and turn Track Changes on and off, while it is on the plugin writing the user's edits as tracked changes. The user's name, which comments and changes carry, is the setting user.name, else the system account's.

9. Versions of the plugin API

The plugin API is the WIT package kalem:plugin (crates/kalem-plugin/wit), version 0.2.16; a component's manifest names the version it was built against ("api": "^0.2.1"), and Kalem tries it when its own API is of the same 0.MINOR and no earlier. A component built against a later version is not installed, and one installed already does not start: Kalem says to update Kalem, naming both versions, where one built against an earlier 0.MINOR is to be updated itself. The rules, so that a plugin built once keeps working:

  • A released interface never changes: no function, field, case or type of it is added, removed or altered. The released ones (viewer, files, clock, grid) are copied in wit-frozen/0.2.0/, and a test (tests/frozen.rs) fails a change to them.
  • What is new goes into a new interface (grid-2, in a file of its own) released with the next patch version; the worlds, kept in worlds.wit, export it. Kalem binds the interfaces a component has, so one built against 0.2.0 still runs on a Kalem of 0.2.1, without what it does not export. An interface the plugin imports is granted by every later Kalem, so a component built against 0.2.1 needs a Kalem of 0.2.1 or later, which its manifest says.
  • 0.2.1 added the import diagnostics: a component built as Rust builds it stops at a panic with a trap that has no message, so the panic hook kalem-plugin's adapters install says it first, and Kalem's log and the stop's error carry the panic's message and where it happened, above the backtrace of the plugin's functions.
  • 0.2.2 added the export password: a viewer whose open answers "This file is protected by a password" (ViewerError::needs_password) opens the file with the one the user types (open-with-password, Viewer::open_with_password); both editors ask for it, hidden, and keep it for the session. kalem-plugin's adapter exports it from the contract; a viewer written against the WIT world itself implements it (opening as open does when it has no such files).
  • 0.2.3 added the export formats: save-as writes a document as another of the formats its plugin writes (a workbook as .ods, a macro-enabled workbook as a plain one; ViewerDocument::save_as), and new-file makes a new file from sheets of entries as typed (Viewer::new_file: a blank workbook, a text file's rows). So the host holds no code of a plugin's formats: Save As, Open as Workbook and New Workbook go through the plugin. kalem-plugin's adapter exports it from the contract, whose defaults refuse.
  • 0.2.4 added the import process and the extension world's export on-process: an extension plugin runs the programs its manifest names (subprocess:git), its runs answered later as its fetches are (kalem_plugin::process). The git plugin of getkalem/plugins is the first to use it. The extension world was not released before, so its plugin interface gained the export; process is frozen with the first plugin released against it.
  • 0.2.5 added the imports documents and decorations: an extension plugin shows documents of its own, which it writes (kalem_plugin::documents, below), and marks the lines of files (kalem_plugin::decorations), for the git plugin's status and its marks beside the changed lines. The git plugin was released against 0.2.4, so process is frozen (wit-frozen/0.2.4), and the extension world's plugin interface no longer gains exports: a component built against 0.2.4 binds it as it is. A plugin learns that the user closed its document from its next write, which is refused.
  • 0.2.6 added the import styled-documents: a plugin's document with colors and emphasis on stretches of its text, written with the text (kalem_plugin::documents::open_styled, below). The git plugin was released against 0.2.5, so documents and decorations are frozen (wit-frozen/0.2.5), and styles came as an interface of their own rather than as an argument of documents.set.
  • 0.2.7 added the exports flow and annotations (above, Viewers of flowing text): flow in the new flow-viewer world (and in full-viewer, which kalem-plugin builds with both its features grid and flow), annotations in every viewer world. A viewer written against the WIT world itself implements annotations, answering none; kalem-plugin's adapter exports both from the contract, whose defaults have no flow and no annotations. styled.wit of 0.2.6 is frozen (wit-frozen/0.2.6).
  • 0.2.8 added the export flow-2 to the flow-viewer and full-viewer worlds: set-paragraphs changes paragraphs' look and lists (alignment, indents, spacing, line spacing, a list of bullets or of numbers in a numbering style named as CSS's list-style-type names it, a list's level, the look given directly cleared), a function flow did not have and, released, cannot get. kalem-plugin's adapter exports it from the contract (flow_set_paragraphs, which refuses by default); a component built against 0.2.7 has none, and Kalem says so when its paragraphs are formatted. flow.wit and annotations.wit of 0.2.7 are frozen (wit-frozen/0.2.7).
  • 0.2.9 added the export flow-3 to the flow-viewer and full-viewer worlds: changes says what changed in a unit's flow since a version, as a run of items replaced and the paragraphs after it numbered on, so that after an edit the host fetches the items that changed rather than the whole flow, which in a long document took most of a keystroke's time. kalem-plugin's adapter exports it from the contract (flow_changes, which answers none by default: the host then reads the flow whole, as it does for a component built against 0.2.8). flow-2.wit of 0.2.8 is frozen (wit-frozen/0.2.8).
  • 0.2.10 added, for extension plugins, the import clock (the time, the time zone, random bits, granted without a permission as the viewers have it), the import layers and the export layer in the world extension-layer (kalem-plugin's feature layer): a plugin's layers over a mode's view, described in Completers, modes and highlighters. The host binds plugin alone and layer when a component exports it, so an extension of an earlier 0.2.x loads as before. flow-3.wit of 0.2.9 is frozen (wit-frozen/0.2.9).
  • 0.2.11 added, for extension plugins, the export completer in the world extension-completer (kalem-plugin's feature completer, which exports layer too): a plugin's completers, declared in its manifest and described in Completers, modes and highlighters; and the import clock-2, the offset of the user's time zone at an instant, which clock gave only as the zone's name. The host binds completer when a component exports it, as layer; an export of an earlier 0.2.x (layer@0.2.10) is found by the later name. layer.wit of 0.2.10 is frozen (wit-frozen/0.2.10).
  • 0.2.12 added, for extension plugins, the imports graph-view (a document drawn as a graph, below) and fs-2 (files moved and put in the trash under fs:write:workspace). completer.wit and clock-2.wit of 0.2.11 are frozen (wit-frozen/0.2.11).
  • 0.2.13 added, for extension plugins, the export layer-2 in the world extension-layer-2 (kalem-plugin's feature layer-2, which exports layer and completer too): a layer's overlays with lines drawn under lines (an embedded block's children, a query's results), described in Completers, modes and highlighters. The host asks layer-2 in place of layer when a component exports it; one built against an earlier 0.2.x keeps its layer. graph-view.wit and fs-2.wit of 0.2.12 are frozen (wit-frozen/0.2.12).
  • 0.2.14 added, for extension plugins, the import problems: what a plugin's tool finds wrong in files, drawn as a language server's diagnostics are (below). layer-2.wit of 0.2.13 is frozen (wit-frozen/0.2.13).
  • 0.2.15 added, for extension plugins, the export layer-3 in the world extension-layer-3 (kalem-plugin's feature layer-3, which exports layer-2 too): lines under lines that show the text of a file, typed into where they are drawn and written back to it, described in Completers, modes and highlighters. The host asks layer-3 in place of layer-2 when a component exports it. problems.wit of 0.2.14 is frozen (wit-frozen/0.2.14).
  • 0.2.16 added, for extension plugins, the import canvas-view: a document drawn as a canvas of shapes where the plugin places them (below); and the export formatter in the world extension-formatter (kalem-plugin's feature formatter, which exports layer-3 and those before it too): a plugin's formatters, declared in its manifest by the text types they format, which Format Document asks (below). The host binds formatter when a component exports it, as completer. layer-3.wit of 0.2.15 is frozen (wit-frozen/0.2.15).
  • Something removed stays as a function that refuses, until a new minor version (0.3) is cut for everything at once.
  • An addition is general, or it waits. It names what several plugins would do with it, not what one plugin needs (process runs any program a manifest grants, documents shows any view a plugin writes, styled-documents colors any of them by the theme's names), and it comes when a plugin needs it, not before: an extension point no plugin uses is a contract kept for nobody. A plugin's need that no general form covers is reached through what exists (kalem.run of an editor command, a panel, a manifest's declarations) until a second plugin shows the shape it should take.

Components from before 0.2.0 (the manifests that say ^0.1, built while the API had no version and changed with every spreadsheet feature) are not tried: Kalem says the plugin needs an update, and its files do not open until then (a build of one's own with viewers built in opens them with its built-in copy). A component that cannot be bound for another reason is treated the same way (in the status bar, and on kalem view's standard error). kalem plugin check starts every installed component viewer once, as opening a file would, and loads every installed extension plugin as starting it would, without activating it; it exits 1 when one cannot run. The Released plugins workflow runs it on the index's plugins every day and when the WIT files change, so a contract change shows which plugins need a new release.

A viewer of sheets of cells, a workbook's or a database's, builds the same way with kalem-plugin's feature grid: its component exports the grid interface beside viewer (the spreadsheet-viewer world), the grid half of the contract typed in WIT (cells and their styles, edits, conditional formats, validations, sorting and filters, charts, pivot tables, undo, macros), and Kalem uses it as it used its native one. A macro's questions cross as answers given so far: the plugin stops at a question it has no answer to, Kalem asks the user and runs it again with the answer. Every function of the contract is carried to components; a test fails when one is added and not carried.

Viewers get one more interface, clock: the time, the user's time zone and random bits. A workbook's NOW(), TODAY() and RAND() need them to compute as Excel does, and they tell the plugin nothing of the user's files; extension plugins have it too (0.2.10), and clock-2 (0.2.11). The Excel plugin's formula engine, IronCalc, comes from getkalem/ironcalc, a fork whose only change lets it read that clock on WebAssembly instead of a browser's JavaScript; built so, the plugin opens, edits, recalculates and saves workbooks exactly as its native build does, which a test compared workbook by workbook, page by page and picture by picture, to the pixel, while Kalem still had native copies.

Built for speed (opt-level = 3, from the crate's release profile) and with WebAssembly's SIMD, which kalem plugin build adds, the PDF viewer renders a page within a fifth of its native speed: 39 ms against 33 ms for a page at a Retina display's size. Built for size it was twelve times slower and no smaller.

10. Commands, keys and events

The extension world is a plugin that adds to the editor rather than opening files. It imports the kalem interface, §11.4's namespace, and exports plugin: activate, deactivate, run-command and on-event. In Rust a plugin implements kalem_plugin::kalem::Plugin and registers its commands with the functions that run them:

use kalem_plugin::kalem::{self, EventKind, Plugin, Reply, Scope};

struct WordCount;
impl Plugin for WordCount {
    fn activate() -> Result<(), String> {
        let mut spec = kalem::spec("wordcount.show", "Word Count", Scope::only(&["org"]));
        spec.keys = vec!["ctrl+alt+w".into()];
        kalem::command(spec, |_args| Ok("null".into()))?;
        kalem::keymap("space n w", "wordcount.show", None)?;
        kalem::on(EventKind::DocumentBeforeSave, |_event| Reply::Proceed)?;
        Ok(())
    }
}
kalem_plugin::export_plugin!(WordCount);

A command's scope is part of its description, so none is registered without one; the host refuses a scope naming no type, and an ID that is not the plugin's own (wordcount.). Arguments and results cross as JSON. kalem::run runs another command, built in or another plugin's, once the plugin's call returns: right after the plugin's command, in its context (a document's edit included), or at the editor's next tick when an event's handler asked; it is refused at once when there is no such command, and its result does not come back. A plugin calls its own commands as functions, since a component is not entered again while it is inside a call. A handle a registration returns may be disposed of; dropped, it keeps the registration, and the host takes back everything a plugin registered when it is deactivated. A vetoable event's first veto is the answer. Each call has the plugin's time budget; a command that loops is stopped.

Kalem loads the extension plugins installed: a component that opens no files. kalem plugin install DIR installs one whose component is built, and at start a thread loads each (compiled once and cached, as a viewer is) and activates it when its manifest's activation says: onStartup, onEvent:NAME at the first event of that name (onEvent:document:open), or onFile at the first file opened that it serves (see 7). With none, a plugin that declares files it serves starts with the first of them, any other with Kalem. A plugin is known by its ID's last part, org.kalem.wordcount as wordcount, and its commands are wordcount.*. Its commands join the palette, the menus' rules and the keymap in both editors as it registers them, its key bindings go in before the profile's and the user's (so that both override them), its subscriptions hear the editors' events, and its notifications show in the status line. A plugin that traps or runs past its budget is stopped, what it registered is taken back, and the user is told.

Neither does an extension plugin need Kalem started again. One installed while Kalem runs (Install Suggested Plugin, Browse Plugins, or kalem plugin install in a terminal) is compiled on a thread and started within a second or two as its activation says, as if it had started with Kalem: it is told of the documents open, each as a document:open, then of app:ready; its layer is drawn on them, and a notes graph's folder it serves is offered as a project then. One removed stops, what it registered taken back; one updated stops, and its new version starts. A plugin that starts after the editors opened their first documents (the first start after an install compiles it) is told of them the same way.

A manifest may add menus to the menu bar (the system's on macOS, the window's own elsewhere, and the list F10 shows in both editors) under "menus": each a title, a when-clause on the document, and its items, the plugin's own commands (titled without their category's prefix) and - between groups. The when-clause sees the document's keys (editorMode, textType, fileKind, editorLanguage) and vcs, the version control holding the file (git, hg, jj, =svn=…, by the nearest folder above it with its marker), or for a document without a file (a new one, a plugin's) the first project's folder, where the plugin's programs run for it; so the git plugin's Git menu shows in a repository only:

"menus": [
  { "title": "Git", "when": "vcs == git || textType == git-status",
    "items": ["git.status", "-", "git.commit", "git.push", "git.pull"] }
]

A manifest may add buttons too, under "buttons": each a title, one of the plugin's own commands and a when-clause as a menu's. Where the clause holds, a button stands beside the File Manager and Projects buttons, on the window's toolbar and in the terminal's list of open files, and it is pressed while one of the plugin's documents is shown. The git plugin's Git opens its status, as SPC g g does:

"buttons": [
  { "title": "Git", "command": "git.status",
    "when": "vcs == git || textType == git-status" }
]

A plugin's command runs from the command line too, without a window, as the editors run it: kalem run PLUGIN COMMAND [FILE...] starts the plugin named (its ID or its commands' prefix) whatever its activation, and runs the command against each file, a folder as its listing, or once without one. What the command asks of the editor follows as in a window: the document it reads and edits, the commands it runs, a save; the programs it runs and the requests it makes are waited for, each within its time limit, and what their answers show or run follows too. The documents it writes are printed, with --format json as a list of their titles, kinds and texts; its notices go to standard error and an error among them makes the exit code 1. Its questions are answered as a script would leave them: a prompt takes the text it offers, else it is cancelled, a choice is left with nothing chosen and a confirmation answered no. A file the command changed is saved, as kalem fmt saves. The folders named, or a file's own, are the plugin's workspace with the projects for that run, so that a plugin with fs:read:workspace reads them. --args gives the command's arguments as JSON. The graph plugin prints a Logseq graph's pages so:

kalem run graph graph.pages ~/notes

Two of the editor's commands take what a plugin knows and the editor does not: Search in Folder (search.folder) takes ignore, patterns in .gitignore's syntax left out besides the folder's own (a graph leaves out /logseq/), and Show in System File Manager (file.reveal) takes a path, a file Kalem does not open, shown in its folder for its application to open.

11. The document

A plugin's command reads and edits the document it runs in through kalem_plugin::editor: its text and selection, its headlines (level, title, TODO keyword, priority, tags, properties, planning, parent and children), the headline at a place or with an ID, those with a tag, a keyword or a property, a headline's body, the TODO keywords and the #+KEY: lines, the element at a place, and its tables with their rows and formulas.

use kalem_plugin::editor::{self, Query};

for h in editor::find(&Query { tag: Some("work".into()), todo: None, property: None }) {
    editor::set_todo(h.start, Some("DONE"))?;
}
editor::transact("Close the work items");

A headline or a table is named by where it starts. The plugin reads the document as its command found it; its edits (insert, replace, the TODO keyword through Org's own change with its logging, the title, tags and properties, promoting, demoting and moving a subtree, a table's field and its recalculation, an ID made on demand, saving) wait until the command returns, then apply in order, each place moved by the edits before it, and one undo takes them all back. An edit naming a place where no headline or table starts is refused at once. Only a plugin's own command reaches the document: its event handlers and panels do not.

12. Settings, files and the network

A plugin reads Kalem's settings (kalem_plugin::settings::get("editor.font_size")) and keeps its own in the user's settings.toml, under its ID:

[plugins."org.kalem.wordcount"]
goal = 1000

settings::own reads them, settings::set writes them (the editors read the settings again), and settings::watch hears a change, whether the user edited the file or the plugin set it.

A manifest describes its settings under "settings", as VS Code's extensions describe theirs, so that the settings panel lists them on the plugin's page and changes them without typing where it can:

"settings": {
  "goal":  { "type": "integer", "minimum": 0, "maximum": 100000,
             "default": 1000, "description": "Words a day" },
  "show":  { "type": "boolean", "default": true },
  "style": { "type": "string", "enum": ["plain", "bars"], "default": "plain" },
  "folder": { "type": "string", "examples": ["notes", "journal"] },
  "kinds": { "type": "array", "items": { "enum": ["org", "markdown"] } },
  "skip":  { "type": "array", "default": [] }
}

A boolean is a switch, an integer steps in its range, a string with an enum steps through its choices and one with examples through them (anything may be typed), an array with an enum in items is a list of choices each in or out, another array a list of texts, and any other type is typed as JSON. A dotted key is in tables within the plugin's: a language plugin describes the settings its server is sent as settings.SECTION.NAME (settings.elixirLS.mixEnv). A key the user's table holds that the manifest does not describe is typed as JSON. For a language plugin the page also has its server and each server's program and environment (servers.KEY.command, servers.KEY.env).

Files, the network and programs are not a plugin's unless its manifest asks for them, with the permissions of §11.6:

Permission Reaches
fs:read:workspace reading files in the projects' folders
fs:write:workspace reading and writing there, moving files and putting them in the trash (fs-2, API 0.2.12)
fs:read:all reading any file
net:fetch:DOMAIN HTTP to that domain and its subdomains; * for any
subprocess:PROGRAM running the program PROGRAM (a bare name: git) in a project's folder

Without one, the plugin is not given the fs, net or process interface at all, and a component that imports it is refused, the interface named (D28). Paths are absolute, .. is refused, and links are followed before the check, so one cannot lead out of a project. A fetch does not make the editor wait: net::fetch returns at once, and the response reaches a closure later, fetched on a thread of its own.

process::run (API 0.2.4) is the same for programs: it returns the run's number at once, and how the program ended (its status, its two outputs, each kept to 16 MB) reaches a closure later. The program is the one the permission names, found as a language server is, on the PATH, or where the user's setting programs.PROGRAM of the plugin says; it runs without a shell (its arguments are a list, never quoted) and without a terminal, in a project's folder (the first project's when none is given), at most 16 at a time for a plugin, killed after ten minutes, with process::kill, or when the plugin stops. The installer's question names it: "Runs programs on this computer: git".

use kalem_plugin::process::{self, Command};

let status = Command::new("git").args(["status", "--porcelain=v2", "-z"]).cwd(root);
process::run(&status, |result| match result {
    Ok(exit) if exit.status == Some(0) => { /* read exit.stdout */ }
    Ok(exit) => { /* exit.stderr says why */ }
    Err(e) => { /* the program could not start: e */ }
})?;

13. What a plugin shows

The same world imports ui, §11.4's kalem.ui: notify, the three questions prompt, confirm and quick-pick, status bar items, and panels. A question never makes the editor wait: the call returns at once, and the answer comes later, a closure in Rust:

use kalem_plugin::ui::{self, Level};

ui::confirm("Archive the done tasks?", |yes| {
    if yes {
        ui::notify("Archived", Level::Info);
    }
});

A panel is a tree of widgets (texts, buttons, inputs, checkboxes, list and tree entries, progress bars, rows and columns) that the window and the terminal each draw with their own; there is no webview (D11). A plugin builds the tree with ui::Tree, adding each widget under one already there, and replaces the panel's content with ui::set_panel; what the user does to a widget reaches the panel's handler with the widget's key. The host checks every tree before an editor sees it. Both editors show them with their own means. A question is one of the editors' own: a line is asked for in the palette (the terminal's minibuffer), a yes or no and a choice from a list are offered as a list in the palette, and the answer reaches the plugin through the command plugin.answer. A status item stands in the status line, a click running its command in the window. A panel opens with View > Plugin Panel (view.pluginPanel, offered while a plugin has one), beside the text or under it; in the window a click on a button, an entry, a checkbox or an input reaches the plugin, and in both editors Panel Actions (view.panelActions) lists the widgets one acts on, so a panel is used from the keyboard too; an input's new text is asked for in the palette. Cancelling a question tells the plugin nothing yet, a list offers one choice even when the plugin allows several, and a password is not hidden as it is typed.

A plugin may also write documents of its own (documents, API 0.2.5): read-only text that the editor shows as a document, in a tab and the list of open files as any other, as it shows the file manager's listing, so that the cursor, search, selection, Vim's motions and scrolling work on it unchanged. The plugin gives it an ID and a key (the same ID and key show the same document again), a title, a highlighter (diff) and a kind, its own ID, a dash and a name (git-status): the text type that its commands' scopes and its keys name, so that s or Tab do what the plugin says there and nowhere else. It writes the text anew whenever it changes; only the part that differs is replaced, the view keeps its place, and the cursor stays on its line unless the plugin puts it elsewhere. There is nothing to undo and no file to save. A command run in it finds the cursor with editor::selected, and which document it is with documents::current. Once the user closes it, the plugin's next write is refused, and the plugin forgets it. Kalem shows the output of the project's run and test commands (SPC p R, SPC p T) in such a document of its own, kind kalem-output, and stops the command when it is closed.

Its keys there are bound with a when-clause naming the kind: textType = git-status && (vimCommand || !vimActive)= applies in Vim's command mode and in the Word-like profile, and such keys win over the profile's and Vim's in the plugin's documents only. So do a plugin's keys in the files of a language its manifest declares, bound with a when-clause naming their text type (textType = journal=), as a major mode's keys in Emacs: the plain text accounting plugin's Enter aligns a posting's amount, as ledger-mode's does, before the new line. In Vim's insert mode the Vim layer takes Enter in a plain text file before any binding. A document a plugin writes has no line numbers and no indentation guides: the plugin lays it out.

use kalem_plugin::documents::{self, Spec};

let spec = Spec::new("git.status", root, "Git: org", "git-status").language("diff");
let doc = documents::open(&spec, &text, None)?;
// Later, as the repository changes:
if documents::set(doc, &text, None).is_err() { /* closed: forget it */ }

A plugin also marks the lines of files (decorations, API 0.2.5): the added, changed and removed lines, which both editors draw beside the lines, a colored bar (in the terminal ▎, and ▁ under a line after which lines were removed); an added or changed line is also tinted under its text, green or orange, so that the change shows where it is (editor.highlight_changes turns the tint off). A plugin sets a file's marks whole, as the file is on disk; the editor moves them with the edits made since, until the plugin sets them again (the git plugin does on each save). Next Change and Previous Change (edit.nextChange, edit.previousChange; Doom's SPC g ] and SPC g [) go from one change to the next.

use kalem_plugin::decorations::{self, Mark};

decorations::set_gutter("/home/a/org/src/lib.rs", &[(12, Mark::Changed), (40, Mark::Added)])?;

A plugin also tells what its tool finds wrong in files (problems, API 0.2.14): errors and warnings, each at a line, a column and a length in characters (column 0 for the whole line from its first non-blank, length 0 for the rest of the line), with a severity, a message and the tool's name. Both editors draw them as a language server's diagnostics: the wavy line under the text, the line number's color, the problem on the cursor's line in the status bar, the hover card and List Problems (code.problems), beside the server's own when the file's language has one, alone when it has none. They are drawn in the files of the languages plugins declare. A plugin sets a file's problems whole, as the file is on disk, and sets them again when they change, after a save; they stay at their lines meanwhile. They ask for no permission: they are drawn, never written.

use kalem_plugin::problems::{self, Problem, Severity};

problems::set("/home/a/books/2026.journal", &[Problem {
    line: 12, column: 0, length: 0, severity: Severity::Error,
    message: "This transaction is unbalanced".into(), source: Some("hledger".into()),
}])?;

A plugin also formats files (formatter, API 0.2.16). Its manifest declares each formatter, an id and the text types it formats ("formatters": [{"id": "journal", "types": ["ledger", "hledger"]}]), and Format Document and editor.format_on_save ask it for the whole text of a file of those types, with the file's path and the type: it answers with the formatted text, or refuses with a message and, when it says, the byte of the text the cursor is put at. It comes after a language server's formatting and a language plugin's commands.format, and before the language's usual program; the formatters setting of the type comes first, and its off turns it off too. Kalem replaces the lines that changed, as one step to undo. A plugin busy with another call is not waited for: the status bar says to format again. It asks for no permission: the text is the editor's, and what it answers goes into the document, which the user saves.

use kalem_plugin::formatter::{self, Refusal, Request};

fn format(id: &str, request: &Request) -> Result<String, Refusal> {
    match id {
        "journal" => Ok(align(&request.text)),
        _ => Err(formatter::refusal("No such formatter", None)),
    }
}

A plugin's document may be styled (styled-documents, API 0.2.6): stretches of its text, in bytes, each with a color and bold, italic or underlined, written with the text so that the two always agree. A color is named, not given (red, green, yellow, blue, magenta, cyan, muted, accent): each theme has its own shade of it, so the document reads in the light and the dark theme; a terminal shows the theme's shade when it shows colors in full, its own named colors otherwise, and bold and underlines without colors. A span inside another wins over it. The git plugin's status colors its files as lazygit does.

use kalem_plugin::documents::{self, Color, StyledSpan, TextStyle};

let styles = [StyledSpan { start: 1, end: 3, style: TextStyle::color(Color::Red) }];
documents::open_styled(&spec, " M README.md\n", None, &styles)?;

A plugin's document may be drawn as a graph (graph-view, API 0.2.12): the plugin gives the nodes, each an id, a label, a group (the nodes of a group share a color) and a weight (how large it is drawn), the edges between them by their ids, a node to keep in the middle if any, and the command run with {"node": id} when the user opens one. The editor lays the nodes out (they push each other away, edges pull their ends together), a few steps a frame until the layout settles, and draws them: in the window as colored dots joined by lines, the labels of the heavier ones and of those near the mouse written beside them; in the terminal with Braille dots. Dragging pans, the wheel (or + and -) zooms, a node dragged moves, a click or Tab chooses one, a double click or Enter opens it. The document's text, written with the graph, is what Show Source, search and a terminal show; written again, the nodes still there keep their places. The graph plugin's Graph View and Local Graph are such documents.

use kalem_plugin::documents::Spec;
use kalem_plugin::graph_view::{self, Edge, Graph};

let g = Graph {
    nodes: vec![graph_view::node("a", "A", "pages", 3), graph_view::node("b", "B", "pages", 1)],
    edges: vec![Edge { from: "a".into(), to: "b".into() }],
    focus: None,
    command: "notes.openNode".into(),
};
graph_view::open(&Spec::new("notes.graph", "", "Graph", "notes-graph"), &g, "A\nB\n")?;

A plugin's document may be drawn as a canvas instead (canvas-view, API 0.2.16), its shapes where the plugin places them, for a whiteboard, a canvas or a diagram with a layout of its own: each shape an id, a kind (a box with rounded corners, an ellipse, text alone, a frame around others, a picture of an image file in Kalem's projects, a pen's line through points), its place and size, its text, its fill and line (a color's name or #rrggbb, else the theme's), and where opening it leads; connectors between shapes by their ids (from the side of one facing the other) or between points, with a label, arrows and a color. The window draws them with lines, fills, pictures and their text wrapped in them, in a font that grows with the zoom; the terminal draws the outlines, ellipses, lines and arrows with Braille dots and writes the text in the cells inside each shape, a picture's name in place of it. Dragging pans, the wheel (or + and -) zooms, 0 fits, a click or Tab chooses a shape (Tab goes through those that lead somewhere, in reading order), a double click or Enter opens it: the canvas's command runs with {"shape": target}. The document's text, written with the canvas, is what Show Source, search and copying show; written again, the view stays where it looks. The graph plugin draws Logseq's whiteboards and Obsidian's canvases so.

use kalem_plugin::canvas_view::{self, Canvas, ShapeKind};
use kalem_plugin::documents::Spec;

let mut page = canvas_view::shape("a", ShapeKind::Box(8.0), [0.0, 0.0, 240.0, 100.0], "A page");
page.target = Some("page:A page".into());
let c = Canvas {
    shapes: vec![page],
    connectors: Vec::new(),
    command: "notes.openShape".into(),
};
canvas_view::open(&Spec::new("notes.board", "", "Board", "notes-board"), &c, "A page\n")?;

The first plugins planned: word count, Pomodoro, an export filter, a table function, a jira: link type, a Mermaid renderer, the kanban board, and modes for todo.txt, TaskPaper, Fountain, gemtext, Djot and more, with the language packs for JSON, YAML, TOML, XML, diffs, ledgers and gettext.