README.md

August 14, 2026 · View on GitHub

███████╗███╗   ███╗ █████╗ ██╗  ██╗      ██████╗ ██╗   ██╗██╗
╚══███╔╝████╗ ████║██╔══██╗╚██╗██╔╝     ██╔════╝ ██║   ██║██║
  ███╔╝ ██╔████╔██║███████║ ╚███╔╝█████╗██║  ███╗██║   ██║██║
 ███╔╝  ██║╚██╔╝██║██╔══██║ ██╔██╗╚════╝██║   ██║██║   ██║██║
███████╗██║ ╚═╝ ██║██║  ██║██╔╝ ██╗     ╚██████╔╝╚██████╔╝██║
╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝      ╚═════╝  ╚═════╝ ╚═╝

Rust GUI license

[A NATIVE DESKTOP GUI FOR ZMAX // THE WAY MACVIM WRAPS VIM]

zmax-gui is a native desktop GUI for the zmax editor — the Rust Emacs port (a Helix/Vim-style modal core built out toward Spacemacs). It wraps the zmax modal-editing core in a windowed front-end, the way MacVim wraps the Vim CLI editor: the same editor underneath, a native window on top. Free and open source.

Architecture

A thin Tauri v2 shell that runs the zmax binary in an embedded PTY terminal (zpwr-embed-terminal) filling the window, wrapped in the shared zgui-core app baseline (ZGui.appShell: command palette, colour schemes, settings, CRT/splash). The editor is the same modal core; the window, chrome and theming are the GUI. Standard MenkeTechnologies GUI layout — see GUI_APP_ARCHITECTURE.md in the meta repo.

The host is thin with one deliberate exception: the office and PDF engines link into it as rlibs so that project search, replace and git blame can see inside binary documents in-process — and so that a document can be opened in the IDE rather than handed to an external viewer. See Documents are searchable, Documents are viewable and Documents are blamable.

zmax-gui/
├─ app/src-tauri/        Tauri host: terminal + fs + window + open-intake + project commands
│   ├─ terminal.rs       PTY spawn/write/resize/kill — the editor's, the floating shell's, and
│   │                    one per tmux tile
│   ├─ fs_ops.rs         list_dir/home_dir — backs the Open dialog
│   ├─ window_ops.rs     fullscreen / translucency (blur) / focus
│   ├─ project.rs        fuzzy find-files, find-in-files (regex), tree file ops, recent files,
│   │                    file stats, git status/branch/diff — the project workbench backend
│   ├─ editor_tools.rs   bookmarks, project search & replace, go-to-symbol, TODO/markers
│   ├─ doc_search.rs     binary-document search & lossless replace (docx/odt/xlsx/ods/pptx/odp/pdf)
│   ├─ doc_blame.rs      git blame at document-address granularity (xlsx/ods cell, pdf page)
│   ├─ git_tools.rs      git blame, per-file history + show-commit, stage/unstage/discard, file compare
│   ├─ git_ext.rs        git branches (list/checkout/create) + stash (save/list/pop/drop/show)
│   ├─ text_tools.rs     file cleanup/convert, sort lines, find-definition, batch rename
│   ├─ edit_ops.rs       align columns on a delimiter + language-aware comment toggle
│   ├─ encoding_ops.rs   detect + transcode a file's character encoding (UTF-8/16, Latin-1)
│   ├─ git_more.rs       repo-wide log, show-commit, diff two revisions, commit graph
│   ├─ workbench_ext.rs  persisted snippets + project code-stats (files/lines by extension)
│   ├─ open_intake.rs    CLI / Finder / mvim:// file opens → :open in the PTY
│   ├─ bus.rs            GUI Automation Bus socket: host commands + webview appshell.* verbs
│   ├─ commands.rs       the host command list the bus advertises (+ its withheld exceptions)
│   └─ txn.rs            content snapshots, the on-disk transaction journal, and the tree witness —
│                        what makes a mutating verb reversible, an interrupted run recoverable, and
│                        a finished run able to name the files its undo did NOT reach
├─ crates/
│   ├─ zmax            the editor — vendored submodule, built → bundled sidecar
│   ├─ zpwr-embed-terminal   shared PTY engine (submodule)
│   ├─ zpwr-file-browser     shared multi-pane file browser: `crate/` (fs_* commands, watcher) + webui
│   ├─ zpwr-i18n             shared 27-locale i18n runtime + catalogs (submodule)
│   ├─ zoffice-core          office engine (docx/odt/xlsx/ods/pptx/odp): rlib + mountable view
│   └─ zpdf-core             PDF engine: rlib + mountable viewer
├─ scripts/
│   ├─ mvim              terminal launcher (open files in the running window)
│   ├─ copy-{webui,embed-terminal,i18n,file-browser,doc-views}.mjs  sync shared webui into frontend/
│   ├─ clean/bust/rebuild/nuke/ship-check/deploy.sh        the shared app lifecycle scripts
│   ├─ run-js-tests.mjs   one discovery path for every JS suite (pnpm test + test:js)
│   ├─ i18n-{sort-catalogs,catalog-audit,extract-seed}.mjs  catalog sort · completeness audit ·
│   │                                                       derive this app English seed
│   └─ prepare-{zmax,stryke}-sidecar.mjs   stage the bundled binaries
└─ frontend/
   ├─ index.html · main.js      mounts ZGui.appShell + the fullscreen terminal
   ├─ menu.js                   the MacVim GUI surface (all zgui widgets → PTY)
   ├─ editor-state.js           editor state reconstructed FROM the PTY stream (the return path)
   ├─ editor-hud.js             buffer/tab bar + status strip + minimap, driven by that state
   ├─ panels.js · panels.css    the project workbench overlays (quick-open, find-in-files, …)
   ├─ doc-view.js               the in-app document pane over mountZpdf / mountZoffice
   ├─ i18n-seed.js · i18n-seed/  this app English seed, merged UNDER the loaded locale
   ├─ fb-backend.js             Tauri fs bridge + host shims for the shared file browser
   ├─ verbs.js                  the TYPED bus surface: the workbench as reversible verbs
   ├─ plan-panel.js             Batch Plan: the shared arrangement grid + the transactional runner,
   │                            its disk journal, and the interrupted-run recovery prompt
   ├─ plan-domain.js            its grid DOMAIN (operations × files) for zpwr-clip-engine
   ├─ vocabulary.test.cjs       drives all three command publishers headlessly (see below)
   ├─ wiring.test.cjs           what those surfaces actually invoke: PTY geometry, document search
   ├─ lib/zgui-core             the shared widget library (submodule)
   └─ lib/zpwr-clip-engine      the shared arrangement-grid engine (submodule)

Project workbench

On top of the MacVim menu surface, the app adds an IDE-style project workbench — all reachable from the ⌘K command palette (and dedicated shortcuts). Every result is opened by driving the editor (:open <path>:<line>:<col>); the OS-side work (walking the tree, grepping, filesystem mutations, git) lives in the Rust project.rs / editor_tools.rs / git_tools.rs / git_ext.rs / text_tools.rs / edit_ops.rs / encoding_ops.rs / git_more.rs / workbench_ext.rs commands, so results are fast and the editor stays the single source of truth.

  • Quick Open (⌘P) — fuzzy file finder over the project tree (VCS/build dirs pruned), boundary- and run-aware ranking; type to filter, //Enter to open.
  • Find in Files (⇧⌘J) — project-wide text search with regex, match-case and whole-word toggles; click a match to jump to its exact line:col, or to bookmark it. Binary documents are searched too, in the same query and the same ranked list — see Documents are searchable below.
  • Search Documents — the documents-only pass (doc_search.rs), for when the question is about the documents rather than the code: the walk is restricted to office packages and PDFs up front, and the two things only this pass can express are yours to set — which formats (a toggle per .docx / .odt / .xlsx / .ods / .pptx / .odp / .pdf; none on means all of them) and whether to match case, plus hidden files. Each hit is addressed the way its format is addressed (¶12, Sheet1!B14, slide 4, p. 7), and picking one opens the document in the IDE, at that address — see Documents are viewable. No regex toggle: the engines are substring scanners, so the backend rejects a regex query outright rather than matching it literally.
  • Search & Replace (⇧⌘H) — project-wide replace with regex (including $1 capture references), match-case and whole-word; a live preview of every before → after line, then Replace All rewrites the matching files on disk (confirmed first). Oversized files, and binaries with no document engine behind them, are skipped like the search; supported documents are previewed and rewritten losslessly.
  • Go to Symbol (⇧⌘O) — a workspace outline picker: functions, structs/classes/enums/traits, types, modules, methods and Markdown headings across the tree (Rust, JS/TS, Python, Go, C/C++, Ruby, shell, Lua, stryke/Perl, Markdown); type to filter, Enter to jump.
  • Find Definition (⇧⌘D) — jump to where an exact symbol name is declared (not every occurrence): reuses the Go-to-Symbol language rules to locate fn/struct/class/def/… sites across the tree; type a name, Enter to jump.
  • TODO / Markers (⇧⌘T) — a scan for TODO / FIXME / HACK / XXX / BUG / NOTE / OPTIMIZE / WARNING comment markers across the tree; filter and jump to each.
  • Bookmarks (⌘B) — a persisted list of named file:line marks (survives restarts); add from a file picker or the button on a search hit, jump on click, remove per-row or Clear.
  • Recent Files (⌘E) — a persisted MRU list (survives restarts; every open, from any route, is recorded), filterable, with Clear.
  • Project Files (⇧⌘E) — a tree file manager: New File / New Folder, Rename, Duplicate, Delete (confirmed), and File Info (line/word/char/byte counts) via the right-click menu; click a file to open it.
  • File Browser — the shared multi-pane zpwr-file-browser (same component as zemail / ztranslator / zstation), opened as a full-screen overlay: multiple panes and tabs, sortable + resizable columns, fuzzy filter, color labels, folder-tree sidebar, text/hex/image quicklook + preview pane, git status, dedup, diff, grep, compress/extract, hash, xattrs, disk-usage and live fs-change watch. Double-click (or Enter) opens the file in the zmax buffer — the browser's "open" is wired to drive the editor, not the OS default app. Backed by the crate's fs_* Tauri commands + the directory watcher (zpwr_file_browser::commands); the front end is synced into frontend/ by copy-file-browser.mjs, bridged through fb-backend.js. Esc or the bar's closes it.
  • Batch Rename — rename every file whose base name matches a find → replace rule (literal or regex with $1 capture refs); a live preview of every from → to (collisions flagged), then Rename All applies it on disk (confirmed). Files stay in their directory.
  • Sort Lines — reorder a file's lines on disk: reverse, ignore-case, numeric and unique (a sorted uniq) toggles, with a dry-run preview of the line-count delta; the file is reloaded in the editor after apply.
  • File Cleanup — normalise a file: convert line endings (LF/CRLF), trim trailing whitespace, expand tabs → spaces or tabify leading indent, and ensure a final newline; a preview reports the changed-line count and byte delta before apply. Binary/oversized files are skipped, like the search tools.
  • Align Columns — align every line of a file on a delimiter (literal or regex), the way Emacs align-regexp lines up = signs, : map keys or // trailing comments into one column; a preview reports how many lines participate and change before apply.
  • Comment / Uncomment (⇧⌘/) — toggle line comments over a line range using the language's comment prefix (//, #, --, ;, ", chosen by extension). If every non-blank line is already commented it uncomments, else it comments; the end line is pre-filled to the file length.
  • File Encoding — detect a file's character encoding (BOM, UTF-8, UTF-16LE/BE, Latin-1) and line ending, then transcode it to UTF-8, UTF-16LE/BE or Latin-1 (UTF-8 output is BOM-free; UTF-16 output is BOM-prefixed); a preview shows the source → target and byte delta.
  • Snippets (⇧⌘I) — a persisted named text library; pick one to insert it into the editor via bracketed paste (multi-line bodies land verbatim, no auto-indent), add / remove / Clear.
  • Git Changes — the current branch + git status list; click a file for its unified diff; Stage / Unstage / Discard (confirmed) each file inline, Refresh, jump to Blame, or open it in the editor.
  • Git Blame (⇧⌘B) — per-line commit / author / date for a chosen file (git blame --line-porcelain); click a line to jump there.
  • Document Blame (⇧⌘Y) — the same question for a binary document, answered at the document's own addresses instead of at lines. See Documents are blamable.
  • File History — the commit log touching a file (git log --follow); click a commit for the diff it introduced (git show), or open the file.
  • Repository Log — the whole repo's commit history (git log, newest first, with ref decorations); click a commit for the full diff it introduced across all files (git show).
  • Commit Graph — the ASCII branch graph across all refs (git log --graph --oneline --decorate --all) in a read-only pane.
  • Diff Revisions — a unified diff between any two revisions (git diff <a> <b>), branches / tags / hashes, optionally scoped to one path; both revisions are flag-guarded.
  • Compare Files — a unified diff between any two files picked from the tree (git diff --no-index, so it works outside a repo too).
  • Git Branches — the local branches (most-recently-committed first, current flagged); click to checkout (confirmed), or New Branch to create and switch (checkout -b). Ref names are flag-guarded.
  • Git Stash — the stash list; click an entry for its patch (stash show -p), Pop (apply + drop, confirmed) or Drop (confirmed) per entry, and Stash Changes to save the working tree (including untracked) with an optional message.
  • Project Stats — a read-only report of file / line / byte counts across the tree, broken down by extension (binary and oversized files skipped for line counting).
  • Batch Plan — the shared arrangement grid over the project: paint which of the reversible operations to apply to which files, then run the whole painting as one transaction that rolls every applied step back if any step fails. See Batch Plan.
  • Interrupted runs — the same transaction, after the app stopped existing. A Batch Plan journals itself to disk step by step, so a run the app died inside is enumerated at the next launch and unwound on request — newest step first, and refusing any file that changed since. See The transaction outlives the process.
  • The reach the undo does not have — a run witnesses the tree before its first step, so when it finishes it can name the files that moved inside its window that no step recorded and it therefore cannot put back. The paths it can are verified the other way, by content, against the run's own pre-image — so an unwind ends with a byte-level receipt rather than its own word for it. See The reach the undo does not have.

Every list, picker and filter in these panels is the shared fzf matcher (ZGui.fzf) with the matched characters highlighted — the same ranking and the same highlight as the ⌘K palette and the file browser, so a query means the same thing wherever it is typed. (The highlight is suppressed while a panel's regex toggle is on: marking a regex's literal characters would mark the wrong ones.)

All surfaces are modal overlays (like the Open dialog) built from zgui-core widgets — no docked pane, so the embedded terminal is never reflowed.

Documents are searchable

Ordinary project search skips any file whose first bytes contain a NUL, which is every office package and PDF — they are zip or binary containers. zmax-gui links zoffice-core and zpdf-core as rlibs into the Tauri host, so the walker gets a second branch: a file whose extension names a supported format is parsed in-process and contributes hits to the same result list as the source files around it. One query returns hits from main.rs and from spec.docx and from budget.xlsx together. There is no subprocess spawn and no IPC per file, and document parsing fans out across a thread pool (the text branch stays single-threaded and unchanged). Two commands reach this: Find in Files, where document hits join the source hits in one ranked list under the grep branch's options, and Search Documents, which walks only the document formats and lets you pick which of them.

FormatSearch hit locatesReplace
.docx, .odtparagraph (¶12)lossless package rewrite
.xlsx, .odssheet + A1 cell (Sheet1!B14)lossless package rewrite
.pptx, .odpslide (slide 4)lossless package rewrite
.pdfpage + on-page rect (p. 7)whole text runs only — see below

Lossless is the literal claim and it is pinned by a test: after a replace, every zip entry other than the edited XML part is byte-identical, so styles, images, themes and revision marks survive a rewrite that a parse-and-re-serialize round trip would silently drop.

Four behaviours differ from the text branch, and each is surfaced in the UI rather than hidden:

  • Literal only. Every engine find is a substring scan, so the document branch is skipped when regex or whole-word is on, and the standalone Search Documents panel rejects a regex query outright instead of matching it literally (it offers no regex toggle at all).
  • PDF replace matches whole runs, not substrings. Searching getUserName in a PDF whose text run reads getUserNameFromDb finds the hit and cannot rewrite it. Those rows are still listed, with an honest 0 and the reason, rather than dropped as a silent no-match.
  • Replace is case-sensitive even when the search that found the hit was not, because the package rewrite edits raw XML text nodes. A case-insensitive replace says so in the preview.
  • Parse failures are reported, not swallowed: a corrupt package appears as its own row with the engine's message, so it never looks like "no matches".

Documents are viewable

Searching a .docx and blaming a .pdf in-process is only half the story if looking at the hit still means leaving for Preview or LibreOffice. It no longer does. Both engines ship a mountable viewzoffice-core's webui/zoffice-view.js and zpdf-core's frontend/js/zpdf.js — and zmax-gui mounts them into a modal pane, over the same rlibs the walker already uses, through one bare app command per engine (zoffice_invoke / zpdf_invoke, registered in lib.rs). No subprocess, no third-party viewer, no export step.

The pane is the reason the address the search found is worth carrying: it is spent on arrival. A p. 7 hit lands on page 7; a Sheet1!B14 hit opens the spreadsheet's Data view; a paragraph or slide hit is searched for in the opened document. Handing the file to the OS default application could never do any of that — an external viewer cannot be told to jump to ¶12, which is why the old path could only put the address on the clipboard.

ExtensionPane
.pdfzpdf-core viewer — pages, text layer, outline, markup
.docx, .odt, .xlsx, .ods, .pptx, .odpzoffice-core view — document / data / analysis
anything elsethe OS default application, with the address on the clipboard

That last row is not a leftover: a format neither engine reads (.epub, .key, …) still opens, and so does a document whose pane fails to mount. The fallback is the exception path, not the default.

Scriptable like everything else here — zmax.doc.open, zmax.doc.close and the zmax.doc.state query are on the automation bus, alongside the pane's own zoffice.view.* verbs, so one stryke script can open a document in the IDE and read it back without a screenshot.

Documents are blamable

git blame answers "who last changed this line". A .xlsx or a .pdf has no lines — git sees one binary blob and reports Binary files differ. The prevailing workaround is a textconv diff driver that shells out to pandoc or unoconv once per file per revision and flattens the document to a throwaway line stream, so the number that comes back is a line of the rendering, not an address in the document.

Document Blame (⇧⌘Y, or the ⌘K palette) answers it in the document's own coordinate system:

Sheet1!B14   a3f91c2e  2026-03-04  <author>  quarterly figures
p. 7         5d10ba71  2026-01-19  <author>  redraft the appendix

The walk reuses what the search branch already built. git log --follow yields the revisions that touched the document (and the path it had at each, so a rename does not break the history); git show <rev>:<path> materializes each blob; each revision is parsed in-process by the same zoffice-core / zpdf-core rlibs described above — no pandoc, no unoconv, no converter subprocess per revision. Each address is then attributed to the newest revision whose content at that address differs from its predecessor's, which is why editing one cell does not re-blame the cells beside it even though their bytes inside the zip moved too. Rows carry the same DocLocator the search rows do, so an address renders identically in both panels.

FormatBlame address
.xlsx, .odssheet + A1 cell (Sheet1!B14)
.pdfpage (p. 7)

Two limits, both surfaced in the panel rather than hidden:

  • Only stable addresses are blamed. Paragraph and slide indices shift when content is inserted above them, so index-keyed attribution would mis-blame every unit below an insertion — wrong in a way that looks right. .docx / .odt / .pptx / .odp are refused with that reason stated, pending content-hash alignment between adjacent revisions. Cells and pages do not move.
  • The revision walk is capped (the panel reports how many revisions it walked out of how many exist). The oldest revision in the window has no predecessor to compare against, so addresses that did not change inside the window are marked changed at or before that commit, not by it. Revisions that fail to parse are listed as skipped, because a revision that could not be read is a gap in the attribution rather than a non-event.

On prior art, since the surrounding claims here are narrow on purpose. Address-granular diff for spreadsheets is not new: ExcelCompare emits DIFF Cell at Sheet1!A3, Git XL and JetBrains' ExcelDiffer do cell-by-cell workbook comparison. Address-granular authorship is not new either — xltrail answers "who changed this value, when and why?" per cell. What is unclaimed elsewhere is the combination this panel occupies: git-backed (a real repository, not a proprietary cloud store), multi-format (spreadsheets and PDF, not Excel-only), and in-editor (a panel in the editor, not a web SaaS). No editor in the category does authorship on a binary document at all: VS Code renders .docx as Binary file not shown, JetBrains' Diff Viewer treats Office files as binary, and Zed, Neovim, Emacs and Sublime have no office/PDF blame path.

Dry run is measured, not predicted: each document is genuinely re-serialized into a temp file beside itself and the count is taken from what the engine actually did, then the temp is discarded. On apply, that temp is renamed over the source — same directory, so the replace is atomic and a failure part-way through can never leave a half-written document on disk.

Transform by Example and Reshape by Example carry an Apply to selector with the same reach: a synthesized rule can run over the buffer (the :%s bridge, unchanged) or over the project's documents. Only a literal replace rule can cross that boundary — the other rules emit whole-line patterns, and a paragraph or a cell is not a line — so the rest are refused with that reason stated, never silently applied to nothing.

Proving a rule before it fires

Both surfaces also carry Verify. A rule derived from two before → after rows is, until it runs, a guess about every line it has not seen; the live preview only exercises it against lines the author typed into the sample box, which is the one place it is guaranteed to behave. The buffer path then writes :%s/…/…/g into the PTY with nothing reading back, so the first evidence of a rule that was too broad is the damage.

Verify runs the same rule over the real project through the host's replace_project dry run (apply: false — it reads every text file in scope and rewrites none) and reports what the rule moves: the number of matches, the number of files, the before→after of each line, and how many of the shown lines the rule matched but left byte-identical. The counts are complete rather than sampled — the host totals every match before it caps the preview list, so a truncated row list still carries an exact total — and because a substitute only rewrites what its pattern matches, every line absent from that count is one the rule provably does not touch.

The one thing it will not do is approximate. A rule is checked only when it has an exact equivalent in the host's regex flavour: literal replace, wrap, and every reshape (whose anchored pattern is already the one each example was verified against, so the check runs the rule itself rather than a re-derivation of it). The three case rules emit vim's \U / \L / \u replacement operators, which the host's regex engine has no counterpart for; rather than measure some other rule and present the number as this one's, Verify says so. Editing any row clears a standing report, so a proof can never outlive the rule it was measured for.

Editor state, reconstructed from the PTY stream

Every GUI wrapper in the MacVim / gVim / neovim-GUI lineage needs the editor to cooperate: MacVim links Vim as a library, nvim --embed is a cooperation protocol, and the rest of the field is built on some RPC socket. zmax exposes none of that — no control socket, no --embed, no IPC. So until now the channel was one-way: the GUI wrote keystrokes into the PTY and nothing ever came back, which is why there was no buffer bar, no live position and no minimap.

frontend/editor-state.js closes the loop without asking the editor for anything. It reads the same bytes the terminal pane already receives and rebuilds enough of the screen to read the editor's own statusline and bufferline back off it. The editor is unmodified and unaware.

  • Why a screen model, not a regex. zmax renders through a cell-diff backend: it emits CSI row;col H and then only the glyphs that changed, wrapped in synchronized-output markers. A statusline is assembled from a dozen scattered writes, so reconstructing it requires a grid. The grid is a deliberate subset of a terminal — cursor motion, erase, SGR foreground, OSC titles — and the parser is a state machine, because the PTY reader frames on 4 KiB with no regard for escape boundaries.
  • Why not xterm.js. The pane's xterm instance belongs to zpwr-embed-terminal, a crate four other apps embed. Exporting its internals to reach buffer.active would fork a shared component for one app's feature; a private subset parser forks nothing and costs one pass over bytes already in this process — no second process, no polling, no LSP.
  • What is recovered: mode, file path, modified / read-only, cursor line:col, scroll percentage, selection count, line ending, encoding, per-severity diagnostic counts (carrying the colour that is the severity — every severity prints the same glyph), and the buffer list when the bufferline is on. The focused view's statusline is found by its mode token rather than by a fixed row offset, because the workbench inserts panels below it and an unfocused split blanks its mode.
  • What is not, and is not claimed: the buffer text (only the visible viewport crosses the PTY, and only as diffs), the total line count, and which diagnostic sits on which line. Those fields read null.

frontend/editor-hud.js spends it: a live buffer/tab bar, a status strip, and a ZGui.codeMinimap with the cursor marked. Clicking a tab is the clearest proof the channel really is bidirectional — the editor has no "switch to buffer N", only next/previous, so a tab bar is impossible without knowing which buffer is active. The minimap's density comes from the file on disk, since the buffer text never crosses the PTY, and its cursor band is exactly one line wide because the cursor line is exact while the scrolled viewport is not carried by the stream at all.

The return path also makes the outbound path faster. Every burst used to open with a defensive Esc plus a 50 ms wait for the editor's esc-disambiguation window, because the GUI could not know the mode. Now menu.js skips both when the reconstruction is trusted and says normal mode — gated on trust, never on the cached mode alone: the GUI's own writes mark the reading stale until the next observed statusline, since between a write and the redraw the mode is exactly what is unknown.

MacVim-style GUI

The GUI wraps the modal core the way MacVim wraps Vim. Every surface is a zgui-core widget; each action is bridged to the editor by writing an ex-command into the PTY (the GUI never edits files itself, it drives zmax). zmax (a Helix fork) has both buffers and a real vim tabpage family, so the GUI drives each with its own menu — the Buffers menu cycles/closes open buffers, the Tabs menu manages tabpages (each holds its own split layout).

  • Menu bar (ZGui.menubar) — File / Edit / Search / Text / Extract / Align / Structure / View / Buffers / Window / Tabs / Folds / Marks / Bookmarks / Macros / Snippets / Code / Spell / Abbrev / Git / Help.
  • Search menu — in-buffer engine commands (distinct from the file-based Find-in-Files workbench): whole-buffer regex Replace (:%s, delimiter auto-chosen so a / in the pattern is safe), case-preserving Replace (vim-abolish :%Sfoo/Foo/FOObar/Bar/BAR), Count Matches (:count-matches), and Clear Search Highlight (:nohlsearch).
  • Text menu — in-buffer, live-selection line transforms bridged into the PTY (distinct from the file-based align-columns / whitespace panels in the project workbench, which act on a picked file): comment / uncomment the selected lines (SPC c ctoggle_comments); sort lines, with reverse / numeric / unique variants (:sort-lines [--reverse|--numeric|--unique]); sort the ranges in the selection (:sort); sort paragraphs (:sort-paragraphs); hard-wrap the selection to the configured width (:reflow); and reindent / dedent by a shiftwidth (:indent-lines / :dedent-lines).
  • Extract menu — regex extraction over the selection, bridged into the PTY: replace the selection with the http(s) URLs / email addresses / IPv4 addresses / numbers / double-quoted strings it contains, one per line (:extract-urls / :extract-emails / :extract-ips / :extract-numbers / :extract-quoted); and extract every substring between a start / end delimiter pair from a prompt (:extract-between <start> <end>, each delimiter shellword-quoted).
  • Align menu — the vim SPC x a column-alignment family bridged into the PTY, acting on the primary selection's rows (distinct from the workbench's file-based Align-Columns panel, which aligns a picked file on disk — the same in-buffer-vs-file split as the Text menu): align the selection's cursor columns (align_selections); align each row at a fixed target character — = / : / , / ; / & / . (numeric), the paired brackets ( ) [ ] { }, or the arithmetic operators (align_at_equalsalign_at_arithmetic); and align at a prompted single character (left / right, align_left_at_char / align_right_at_char) or a prompted regexp (align_at_regex).
  • Structure menu — the vim SPC k paredit/sexp structural-editing family bridged into the PTY (plus the split verb on SPC j s): sexp navigation — beginning / end of sexp, up to parent, next / previous sexp, forward / backward to the enclosing paren, matching paren, copy sexp; slurp / barf forward and backward (paredit_slurp_forwardparedit_barf_backward); wrap with parens, unwrap (splice), raise, transpose, split, join, convolute, absorb (wrap_sexp / paredit_splice / paredit_raise / paredit_transpose / paredit_split / join_selections / paredit_convolute / paredit_absorb); splice-killing forward / backward and insert-sexp before / after (paredit_splice_kill_forward / …_backward, paredit_insert_sexp_before / …_after); and delete sexp / symbol forward and backward. The submap's generic vim reuses (visual select, undo/redo, mode switches, paste) are omitted — they already live on the Edit menu and are not structural ops.
  • Code menu — language-server actions bridged into the PTY: go to definition / references / type definition, hover docs, peek definition, signature help, document / workspace symbol pickers (SPC s j / SPC s S), the refactor set — rename symbol, code action, organize imports, implement / override members, generate code (SPC l r/a/O/i/v/g) — next/previous diagnostic, format document, restart language server.
  • Spell menu — vim's spell-check family bridged into the PTY: suggest corrections for the word under the cursor (z=), jump to the previous / next misspelling ([s / ]s), add a word to the dictionary or mark it misspelled and undo that (zg / zw / zug), edit the wordlists by typing words (:spellwrong / :spellrare / :spellundo), and list the known-good words / show wordlist info (:spelldump / :spellinfo).
  • Abbrev menu — vim/emacs abbreviation-table commands bridged into the PTY: list every defined abbreviation (:list-abbrevs); define a global / both-mode / insert-mode / command-mode abbreviation from a lhs + expansion prompt pair (:define-global-abbrev, :abbreviate, :iabbrev, :cabbrev); remove one for both / insert / command mode (:unabbreviate / :iunabbreviate / :cunabbreviate); expand every abbrev in the region (:expand-region-abbrevs); clear all (:abclear) or kill every table (:kill-all-abbrevs); and load / save the table to a file (:read-abbrev-file, reusing the Open browser / :write-abbrev-file, reusing the Save-As path prompt).
  • Git menu — zmax-vcs actions bridged into the PTY: Magit status, stage / unstage file, line blame, buffer-vs-HEAD diff, next/previous/reset hunk, stash / pop, and merge-conflict resolution (3-pane resolve, keep ours / theirs, next conflict).
  • Window menu — vim's C-w split-window family bridged into the PTY (each key backed by a real editor command): split horizontally / vertically, focus the split to the left / down / up / right (C-w h/j/k/l), move the current split to an edge (C-w H/J/K/L), rotate splits forward / reverse and exchange with the next (C-w w/R/x), grow / shrink height and width and equalize (C-w +/-/>/< / C-w =), maximize by closing the others (C-w o), close the split (C-w q), and undo the last layout change (C-w u, winner-undo).
  • Tabs menu — vim's tabpage family bridged into the PTY (real tabpages, distinct from buffers — each carries its own split layout): new tab / new tab with file (:tabnew, the latter reusing the Open file-browser), close / close-others (:tabclose / :tabonly), next / previous / first / last (:tabnext / :tabprevious / :tabfirst / :tablast), move to end / to position (:tabmove), run an ex-command in every tab (:tabdo), and the visual list / switch picker (:tabs).
  • Folds menu — vim's z-family fold ops bridged into the PTY: toggle / open / close the fold at the cursor (za / zo / zc), open / close all folds (zR / zM), create a fold over the selection (:fold), delete one / all folds (zd / zE), and walk to the next / previous fold (zj / zk).
  • Marks menu — vim's position-and-register family bridged into the PTY: set / go-to / list / delete marks (:mark, `{x} goto, :marks, :delmarks[!]); jumplist back / forward (C-o / C-i), list / clear jumps (:jumps, :clearjumps), recent-files picker (:oldfiles); and registers show / set / clear / clear-all (:registers, :set-register, :clear-register).
  • Bookmarks menu — zmax's persistent-bookmark family bridged into the PTY (distinct from the transient marks above): JetBrains-style line bookmarks — toggle at point, next / previous, jump via a picker (SPC r t/n/N/j); focus the Bookmarks tool window (SPC W b); and the emacs bookmark file I/O — save / load the bookmark store to a path (:bookmark-write / :bookmark-load, via the Save-As prompt and the Open file browser).
  • Macros menu — vim's keyboard-macro family plus the Spacemacs SPC K kmacro tree bridged into the PTY: record into a register / stop (q{reg} / q), replay a register / the last one / re-run the last ex-command (@{reg} / Q / @:); the macro ring — cycle next / previous, view / swap / delete the head macro (SPC K r n/p/L/s/d); the macro counter — increment / insert-and-increment (SPC K c a/c); and save the last macro to a register (SPC K e r).
  • Snippets menu — the PTY-native snippet library bridged into the PTY (distinct from the workbench Snippets panel): insert a snippet via the fuzzy picker (:Snippets) and open the library editor to create / edit / delete snippets (:snippets).
  • Toolbar (ZGui.buttonBar) — new / open / save / buffer nav / find / replace / go-to-def / format / git status / list marks / replay macro / toggle fold / comment lines / list tabs / split / full screen.
  • Command palette (⌘K) — every menu action, fuzzy-searchable, and each one also callable from a script or a saved command chain (see Scriptable).
  • Cmd-shortcuts — ⌘S save, ⇧⌘S Save As, ⌘O open, ⌘W close buffer, ⌘N new, ⌘Z/⇧⌘Z undo/redo, ⌘F find, ⌘G/⇧⌘G next/prev, ⌘{ ⌘} buffer cycle, ⌃⌘F full screen.
  • Tmux tiling (⌘K ▸ Tmux) — the shared ZGui.tmux overlay, wired by frontend/tmux-config.js so each tile is a separate editor: its own xterm on its own backend PTY session (term_session_*), with the editor exec'd into it exactly as the fullscreen one is. C-b is the prefix (C-b c new window, % / " split), and the always-on editor pane hides while the overlay is up.
  • Floating shell (⌘K ▸ Terminal) — a scratch login shell in its own pane on top of the IDE, on its own PTY (shell_term_*), so it never disturbs the editor's. Its geometry tracks the pane: the PTY is spawned at the size the pane is actually drawn at and re-sized whenever that changes, through the same cell-metric fit the embedded terminal uses for its own PTY (zpwr-embed-terminal's window.zpwrTermFit), so full-screen programs — vim, less, htop — draw to the right width instead of to the geometry the pane happened to have at boot.
  • Open / Save As / Help dialogs (ZGui.modal + ZGui.tree file browser).
  • Right-click context menu in the editor (ZGui.contextMenu).
  • Drag-and-drop files to open (ZGui.fileDrag).
  • Full screen + translucent background (window-vibrancy); Preferences panel.
  • Open from the terminal / Finder / mvim:// URL, forwarded into the running window (single-instance + deep-link). Use scripts/mvim file….

Out of scope (no surface in a PTY/WebView host — they need a native text view): native font rendering (ligatures, thin strokes, antialias), Touch Bar, macOS Services, Force Click / dictionary lookup, trackpad gesture pseudo-keys, find-pasteboard sharing. A passive always-on tabline strip is omitted on purpose — a faithful one needs editor↔GUI introspection the raw PTY doesn't expose, and a drifting strip would lie about state; the Tabs menu + the on-demand :tabs picker (rendered by the editor itself) cover switching without that risk.

Scriptable: every GUI action is a bus verb

The app opens its GUI Automation Bus socket at startup (bus.rs), so a stryke script reaches it by name — App::open("zmax-gui"), or App::here() from a hook running inside the app. The surface is published over two routes and verbs() returns their union:

RouteWhat it exposesDispatched by
hostthe app's own #[tauri::command] surface — project + document search / replace, blame, git, text and editor tools, the file browser, the terminalthe host's own IPC, by name
webviewappshell.<id> — the shell's built-ins plus every command the GUI publishes: the whole MacVim menu tree, the project workbench overlays, and the shell's own commandsautomation-host.js in the webview

commands.rs is the host list, and NOT_ON_BUS is its explicit exception list — the bridge's own zgui_bridge_reply / zgui_bridge_event plumbing, withheld because a script that could reach them would be able to resolve another caller's in-flight request or forge an event, plus log_diagnostic (below), which reports on the app rather than driving it. A drift-guard test requires every registered command to appear in exactly one of the two lists, so adding a command forces a deliberate decision instead of a silent omission.

The webview half turns on one detail. The appShell registers an appshell.<id> verb for each command published through setCommands; its older setPaletteItems entry point fills the ⌘K palette and registers nothing. All three of the app's publishers — main.js (the shell's own commands), menu.js (the menu tree) and panels.js (the workbench) — go through setCommands, and because that call replaces the vocabulary each publisher hands over the union rather than its own slice.

Every id is locale-independent: zmax.<action> for a menu command (naming the entry in the ZmaxMenu.actions bridge table, the same key the native menu uses for its item ids and accelerators) and zmax.panel.<surface> for a workbench overlay. An id derived from a label would be a translated string, so switching language would rename every verb and break every saved script and command chain that referenced one. frontend/vocabulary.test.cjs drives the three real publishers headlessly and pins all of it: the union is published, through setCommands, with no id claimed by two different actions, and with every id unchanged across a locale switch.

The toolkit checks the same two rules from its side. On every publish the appShell audits the vocabulary for a row with no id (it shows in ⌘K but can never become an appshell.<id> verb) and for an id containing whitespace (the fingerprint of a translated label used as an id), and records what it finds in window.ZGui.diagnostics and on a zgui:diagnostic document event. It prints nothing. main.js forwards each note to the log_diagnostic host command, which appends it to zmax.log — the file the Settings ▸ Diagnostics Open log file button reveals. Nothing reaches the terminal. zmax-gui's own vocabulary raises none of them — every published row carries a stable zmax.* id — so the channel is there for a future regression, not for a current one.

frontend/wiring.test.cjs is the other half of that: the vocabulary test proves the rows exist, this one drives the real main.js / panels.js against a stubbed Tauri host and asserts what they send to Rust — the floating shell's PTY geometry (at spawn and on every later resize) and the documents search with its formats filter.

Reversible verbs: a refactor is a transaction

The two routes above describe what a script can reach. This section is about what happens when a step fails halfway.

The bus classifies every verb as pure, inverse or irreversible, and a transaction decides on that classification: an inverse verb is journaled so it can be compensated, an irreversible one is refused before it runs. A palette row declares nothing, so it defaults to irreversible — right for a button, and a ceiling: it means a multi-step project edit driven from a script either succeeds or is left half-applied.

frontend/verbs.js publishes the workbench itself as a typed, parameterised surface on top of that: reads and previews as pure, and every file mutation as inverse with a real undo().

ClassVerbsCompensation
purezmax.project.* (find files, search, symbols, markers, stats), zmax.git.* reads, zmax.doc.blame, zmax.txn.{snapshots,interrupted,coverage,record}, and every *.preview dry runnone needed — nothing is written
inversezmax.replace.apply, zmax.rename.apply, zmax.sort.apply, zmax.cleanup.apply, zmax.align.apply, zmax.comment.apply, zmax.encoding.apply, zmax.doc.replace, zmax.file.{create,rename,copy,delete}, zmax.git.discarda content snapshot taken before the mutation (txn.rs) and written back by undo()
inverse (paired)zmax.git.stage / zmax.git.unstage, zmax.bookmark.add, zmax.snippet.addthe opposite command
irreversiblezmax.git.{checkout,createBranch,stashSave,stashPop,stashDrop}, zmax.editor.{open,ex}, zmax.doc.{open,close}, zmax.txn.unwindnone — repository-wide state, the editor's own buffers, or (for the unwind) a rewrite of the whole tree a transaction touched

A mutating verb learns the exact paths it is about to touch from its own dry run, snapshots those, then applies. So zmax.replace.apply snapshots the files its preview named — source files and binary documents alike — and undo() restores every one of them byte for byte.

Three refusals are load-bearing, because the alternative in each case is a verb that looks reversible:

  • A truncated preview refuses to run. If the result cap cut the file list short, the snapshot would cover a prefix of what the mutation edits, so the verb rejects the call instead of half-covering itself.
  • A mutation that changed nothing releases its snapshot and reports no token, so a later abort cannot rewrite a file that verb never touched.
  • A compensation refuses a file the world moved past. A mutation that landed seals its snapshot (txn_seal), recording a SHA-256 of what it left at every path it took. A sealed txn_restore compares each file to that fingerprint, and where they differ it does not write: it parks the pre-image beside the file as <name>.zmax-undo-<token> and reports the path under conflicted. So an abort cannot destroy an edit that arrived after the step ran — the user's own, a formatter-on-save, an LSP code action, or another instance driving the same tree — and cannot lose the pre-state either. conflicted is not a failure; declining is the correct outcome, and the status line says how many files were left alone rather than claiming a clean rollback.

frontend/verbs.test.cjs drives the real surface against the real automation.js and asserts the protocol at the wire level — which paths were snapshotted, that the snapshot happens between the dry run and the apply, that an abort compensates newest-first, and that an irreversible verb is refused inside a transaction before it runs.

The transaction outlives the process

automation.js journals a transaction in the webview's memory. That unwinds a step that failed while the app is alive, and it is nothing at all if the app dies mid-run — which is the one moment a forty-file refactor most needs its pre-images. A partially rewritten tree is then just a tree, and the snapshots that could put it back are orphans nothing can attribute.

So a run also writes itself to disk, through txn.rs's journal: txn_open before the first step, txn_append after each step lands (never before — a step that has not run has nothing to compensate), txn_close at either end. Each write goes down as a temp file that is flushed to the device and then renamed over the old one, so a crash sees the previous journal or the next one and never a torn file.

A journal with no recorded outcome is a transaction that was interrupted:

commandverbwhat it does
txn_pendingzmax.txn.interrupted (pure)every run that was never closed, newest first
txn_unwindzmax.txn.unwind (irreversible)compensate one, newest step first, through the sealed restore

At launch the app checks for them and says so once, as a toast; ⌘KInterrupted runs lists each with its label and step count and unwinds one on click. It is never automatic — unwinding rewrites files, and a run that was interrupted is not always a run that was unwanted, so the app reports what it found and the choice stays the user's. Every restore on that path is the sealed, conflict-refusing one, so a file touched since the crash is left alone with its pre-image parked beside it.

frontend/plan-journal.test.cjs asserts the wire protocol: that a landed step seals after the mutation and before the result is returned, that steps are journalled after they land and in run order with their own tokens, that both the committed and the aborted path close the journal, that a declined file is surfaced rather than absorbed into "compensated", and that a host with no txn_* backend still runs on the in-memory journal alone. txn.rs's own tests drive the crash end to end: open, two steps, no close — then find the journal and unwind it back to the original bytes.

The reach the undo does not have

Everything above is the transaction's account of itself, and a transaction can only ever account for what its own steps named. The tree is not the transaction's: the checkout is shared with the editor, with save hooks, with an LSP writing caches, and — in the setup this app is built for — with the other instances of it running in the other tmux panes. So "rolled back 12/12" can be true about the run and false about the tree, and nothing in the paperwork above can tell the difference.

A run that names its roots gets a witness first: every file under them stamped by length and mtime, with no content read. When the run closes, the stamps are taken again and diffed, the paths the steps declared are subtracted, and what is left is the run's undeclared reach — real changes, inside the run's window, that no compensation covers. They are reported, not compensated; naming them is the only honest thing a transaction can do about a file it never recorded.

The declared half is verified the other way round, exactly, by content: each declared path is hashed against the run's own pre-image — the blob recorded by the earliest step that touched it, since a later step recorded what the previous one left, not what the run started from. Equal is at_preimage; unequal is a divergent entry naming both hashes. After an unwind an empty divergent is byte-level proof that the tree is back where the run found it, rather than the restore's own word for it.

commandverbwhat it does
txn_open(label, roots)witnesses roots before the first step; omitting them keeps the older, unwitnessed behaviour
txn_coveragezmax.txn.coverage (pure)declared / undeclared / at_preimage / divergent for one run
txn_journalzmax.txn.record (pure)the whole record, receipt included

The receipt is computed inside txn_close, while the pre-image blobs it hashes against still exist, and stored on the journal — so it outlives the snapshots and a peer process can read back what a finished run could and could not take responsibility for. zmax.txn.recovered carries the same two numbers, so a subscriber learns in one message that a run was undone and how much of the tree that undo never reached. witnessed: false means the run named no roots: an empty undeclared there means "nobody looked", never "nothing else moved", and the Batch Plan's status line prints nothing rather than a zero for it.

The two mechanisms are split on cost, not on taste. Stamping is one syscall per file; hashing is every byte. Measured over this repository's own tree as it stood at the time — 28,297 files with 2.89 GB under them — the witness cost 782 ms to take and 641 ms to diff, where hashing the same files in parallel cost 12.7 s per pass. The exactness that buys is spent where correctness needs it: on the handful of declared paths, where a stat comparison would not be good enough. The stat half's honest limit is a rewrite landing in the same nanosecond at the same length; it is a false negative in the undeclared half only, and the declared half cannot miss it.

Batch Plan: paint the refactor, run it as one transaction

⌘KBatch Plan is the user-facing half. It embeds the shared zpwr-clip-engine arrangement grid — the same canvas renderer, model and interaction layer the DAW uses — driven by zmax-gui's own domain (frontend/plan-domain.js). Lanes are the reversible operations, columns are the project's files in run order, and a painted cell means "apply this operation to this file".

Run executes the whole painting inside one bus transaction, column by column (everything for one file, then the next — the order the grid reads in). If any step fails, every file already rewritten is restored and the panel reports how many steps were compensated and how many were not. A plan is persisted, so a painted cell whose file has since disappeared is dropped and counted rather than silently retargeted at whatever file now occupies that column.

Hooks editor

⌘KHooks editor opens the shared Monaco surface (zpwr-hooks-editor + ZGui.hooks) over the app's own hook points — editor started / restarted, file opened / saved, project opened, search run, git committed, terminal spawned, locale changed, app quit — each one a stryke script the host runs through run_stryke_hook, with the stryke LSP wired in for completions and diagnostics.

The bar names the runtime that will actually execute those scripts: sidecar.rs's resolution order is STRYKE_BIN, then the bundled sidecar, then PATH, and the strip shows which one answered (full path on hover). When nothing answers it says stryke: not found in red — hooks and the LSP cannot run, and saying so up front is the difference between a diagnosis and "my hook did nothing".

Bundled binaries (self-contained)

The app bundles both the zmax editor and the stryke runtime as Tauri externalBin sidecars — it never depends on either being on the user's PATH. Before each dev/build, scripts/prepare-{zmax,stryke}-sidecar.mjs stage the binaries into app/src-tauri/binaries/<name>-<target-triple> (the name externalBin requires); at runtime sidecar.rs resolves the sidecar beside the executable (or the dev staging dir) and the editor is launched by absolute path, with STRYKE_BIN exported to the bundled stryke. The staged binaries are gitignored build artifacts.

  • zmax — vendored as the crates/zmax submodule and built by the prep script (cargo build --bin zmax); override with ZMAX_SIDECAR_BIN.
  • stryke — pulled from the latest strykelang GitHub release for the host triple (cached by release tag); falls back to a local stryke offline; override with STRYKE_SIDECAR_BIN.

Build

git submodule update --init --recursive   # zgui-core, zpwr-clip-engine, zpwr-embed-terminal, zpwr-file-browser, zpwr-i18n, zmax
pnpm install
pnpm dev            # or: pnpm build

The first run builds crates/zmax (Helix-fork workspace — a few minutes) and downloads the stryke release; both are cached afterward.

The script surface is the family's, so any app is driven with the same muscle memory:

ScriptDoes
dev / tauri:dev · build / tauri:buildrun / bundle the app
tauri:build:citauri build --ci --no-sign
clean · bust · rebuild · nukepurge artifacts · rotate the asset ?v= · bust+clean+build · that plus the WebView caches
ship-checkthe pre-ship gate: submodules checked out and matching their recorded pointer, every app script referenced by index.html, no NUL byte in a frontend asset, JS + Rust suites, i18n audit
deploybuild, clear the WebView caches, launch
test · test:js · test:rusteverything · the JS suites · cargo test
doc · doc:open · doc:synccargo doc for the host, opened, or synced into docs/api
i18n:sort · i18n:sort:check · i18n:auditsort the catalogs · check without writing · read-only completeness audit
i18n:seed · i18n:seed:checkre-derive this app's English seed from its call sites · fail if it is stale
build:hooks-editorrebuild the vendored Monaco hooks-editor bundle

Two of those are worth knowing about before they surprise you. ship-check flags a detached submodule worktree that differs from the recorded pointer, because that reproduces stale sources at build time while git status looks clean. And the catalogs i18n:sort writes live in the shared zpwr-i18n submodule, not here — the resulting diff is committed there.

The English seed

Every translatable string in this app is written twice already: once as a key and once as the English literal the call site falls back to — T("zmax.file.save", "Save"). That makes the English catalog derivable, and deriving it is the only way it stays true; a hand-maintained copy drifts from the call sites the moment somebody edits a label. i18n:seed extracts every zmax.* key into frontend/i18n-seed/en.json, and frontend/i18n-seed.js merges it underneath whatever locale is loaded, so a shipped translation always wins and the seed only fills what no catalog answers. Registering it the obvious way, as an __i18nExtraBases entry, merges the other way round and would override real translations with English — which nothing on an English screen would reveal.

It is English-only on purpose. Generating 26 more locales from an English string is not translation, and a fabricated catalog is worse than a missing one: the runtime reads any present value as a successful lookup, so machine-filled French would render as French with nothing to distinguish it from the real thing. i18n:seed:check fails if a second file appears in that directory, if the seed is stale, or if one key is used with two different English strings — which is how three genuine bugs surfaced (zmax.macros.letter_msg was simultaneously the Record, Replay and Target register prompt).

Translations still belong in the shared zpwr-i18n catalogs. The seed is the extract to hand a translator, not a place to translate.

Releases

Pushing a v* tag runs .github/workflows/release.yml, which builds the macOS app on Apple-silicon (aarch64) and Intel (x86_64) runners and attaches the per-arch .dmg + zipped .app to the GitHub release. The bundled zmax (release build of the submodule) and stryke (latest release) sidecars are staged automatically by beforeBuildCommand, so each .app is self-contained.

git tag "v$(node -p "require('./package.json').version")" && git push --tags

License

Free / OSS — MPL-2.0 (zmax / Helix lineage). See LICENSE.