Releases: pykeko/Moorhen-PyKeko
Release list
pk-v0.3.9 — small overnight-hunt hardening
v0.3.9 — small overnight-hunt hardening
Three JS-only quality-of-life fixes accumulated overnight. Same WASM as v0.3.8.
moorhen_run_js MCP verb now returns the actual value
Previously moorhen_run_js({script: "return 42"}) returned {} at the MCP client because MoorhenControlApi.runJs discarded the script's return value (line 376: await api.exe(script); return {ok:true};) — the MCP wrapper then read r.kind / r.repr (both undefined) and JSON-stringified to {}, silently swallowing every successful result. Now pipes through the same summarizeForRepl helper evalJs uses; response is {ok, kind, repr} on success and {ok:false, error} on throw / syntax error.
Bare expressions like runJs("1+1") still return undefined — that's MoorhenScriptApi.exe's scoping contract (wrapped in an async arrow function; bare expressions in a function body don't return). Callers use return 1+1 for value form. moorhen_eval has an expression-first fallback for that convenience; runJs doesn't, by design.
evaluateSelection("") errors clearly instead of silent count=0
MoorhenControlApi.evaluateSelection("") (or with whitespace-only input) used to return {ok:true, count:0, cids:[]} — indistinguishable from a valid expression that legitimately matched nothing. A caller passing an unfilled template variable got zero atoms back with no signal that the input was bogus. Now:
{ok:false, error:"Selection: expression is empty. Pass a non-empty selection (e.g. 'chain A', 'resn 6ZN', or a saved-selection name)."}
distance warning identifies which side is empty
Previously distance: empty selection — user had to guess which of sel1/sel2 was the problem. Now says exactly:
distance: empty selection — sel1='//A/481/SG' matched 0 atoms; sel2='//A/481/CB' matched 1 atom
Small but load-bearing when a residue-typing mistake (e.g. 481 is ASN not CYS, so SG doesn't exist) causes the surprising empty result.
Also worth knowing (verified overnight, no fix needed)
.pykekoautosave restoration works end-to-end. Recovery toast fires 1 min after relaunch;File → Recover autosavemenu lists candidates; autosave bytes decode correctly via the same protobuf pathFile → Open sessionuses.- Undo/redo cycles cleanly for Coot mol-level operations (delete → 236 atoms; undo → 276 restored; redo → 236 again).
- Session vectorData (H-bonds, distances, etc.) round-trips correctly through the load path.
- Symmetry perf at large radii (400 Å → 3.6 s, 126k trace segments) is by design of the fixed 3×3×3 unit-cell search envelope; not blocking normal use.
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.
pk-v0.3.8 — distance persistence + parser diagnostics + mol-scoped selections
v0.3.8 — bundled fixes from the overnight hunt
Three real user-facing fixes landed since v0.3.7.
Distance measurements now persist end-to-end (closes #203)
v0.3.6 fixed the scripted distance foo, //A/45, //A/46 command — now v0.3.8 fills the noted gap: the interactive m-key click distance handler in mgWebGL.tsx:5719 also dispatches a persistent vector to Redux vectorsSlice (uniqueId prefix pykeko-distance-mclick-). Both scripted AND interactively-drawn distances now survive .pykeko save/reload + autosave/recover cycles alongside H-bonds and other overlays.
Bonus fix caught along the way: labelMode: "custom" isn't a valid VectorsLabelMode (allowed values: none | start | end | middle). vite's TS check tolerated it in the v0.3.6 cmdDistance code because env.dispatch(...) erases types structurally, but mgWebGL's direct import surfaced the mismatch. Both sites now use "middle" — labels render mid-line, as intended.
PyMOL selection parser: warn on silently-skipped chars
Same class as the / bug fixed in v0.3.5. Any char not in whitespace | digit | PUNCT | isIdentStart was silently swallowed by the tokenizer at MoorhenPymolSelectionParser.ts:196-197. Users typing common typos got accidental scope:
resi 100..200→resi 100(stray.and200gone)chain A%→chain Achain A@B→chain A
Now tokenize() returns {toks, skipped} and parseSelection() collapses consecutive runs of the same skipped char into a single line: [pymol] parser: skipped unrecognized char(s) in "<src>" — '.' at pos 9, '%' x1 starting at pos 11. Behavior unchanged for callers; only new output is the diagnostic.
Mol-name-scoped PyMOL commands stop silently coloring the wrong molecules
Three related bugs in the mol-scoped selection path meant color red, 5L0E and chain A (with 5L0E and 1CRN both loaded) was coloring chain A on BOTH molecules instead of just 5L0E.
- Tokenizer lowercased digit-led idents:
MoorhenPymolSelectionParser.ts:127did.toLowerCase()on5L0E→5l0e, thenscopeOf's fallbackmol.name === namefailed the case-sensitive compare against"5L0E", and the whole scope silently fell back to allMols. Now: preserves original case (digit-led idents are never keywords). - scopeOf now case-insensitive: even with case preserved at tokenize time, users typing
1crn and polymershould still resolve. PyMOL convention is case-insensitive object matching. matchPred("object")returned false:MoorhenPymolFilter.ts:215-218treated object names as always-false at the per-atom level, which killed runtime-evaluated intersections like1CRN and polymer(per-atomand(object, polymer)short-circuited to false regardless of scope). Now returns true — object names are a SCOPE-level concept, so once we're inside a scoped mol they should match every atom.
Verified end-to-end on 5L0E + 1CRN both loaded:
color red, 5L0E and chain A→ 5L0E only, cid//Acolor blue, 1crn and polymer→ 1CRN only, cid//A/1-46/*color green, 1CRN and chain A→ 1CRN only, cid//Acolor yellow, 5l0e and resn 6ZN→ 5L0E only, cid//A/911/*||//B/908/*
Multi-mol scope without prefix (color red, chain A) already worked correctly — applied per-mol — and continues to.
Same WASM
No coot rebuild. JS-only fixes. Reinstall via the dmg.
pk-v0.3.7 — autosave .pykeko sessions every 5 min
v0.3.7 — autosave .pykeko sessions
New feature. Never lose a session again. Every 5 minutes the app writes a lean protobuf-encoded snapshot to ~/Documents/PyKeko-autosave/<mol-name>-<ISO-timestamp>.pykeko. On the next launch, if a recent autosave exists, a non-modal toast surfaces it.
Requested + design-approved by @hilgersmt on 2026-07-12 evening.
How it works
- Cadence: every 5 minutes.
- Guard: skips if 0 molecules are loaded, if the state hash (proxied by protobuf byte length) hasn't changed since the last save, or if the
MoorhenTimeCapsuleref isn't wired yet. - Write path: renderer calls
fetchSession(false)(lean; no MTZ payloads) → encodes viamoorhensession.Session.encode→ hands bytes to__moorhenControl.autosave(bytes, molName). No native dialog. - Location:
~/Documents/PyKeko-autosave/. User-visible in Finder — grab any autosave and email it, back it up, drag it into another PyKeko launch. - Filename shape:
<sanitised-mol-name>-<ISO-timestamp>.pykeko, e.g.5L0E-2026-07-12T20_04_16.pykeko. Colon in the timestamp is swapped for_so Finder can display it. - Retention: last 20 files per launch, older files pruned on each new write.
- In-flight guard: concurrent ticks are dropped (never encodes twice in parallel).
- Warmup: first tick fires 60 s after mount; interval fires every 5 min thereafter.
- No-op in browser build: skips if
__moorhenControl.autosaveisn't exposed.
Recovery UX
- Startup snackbar: if the newest autosave is < 24 hours old, a non-modal info toast appears in the corner:
"Autosave from N min ago available — File → Recover autosave"(12 s auto-hide). Fires regardless of the on/off toggle so users who disabled autosave later can still recover from an older one. File → Recover autosave…menu item (desktop build only): pulls the autosave list, iterates newest-first with a serialwindow.confirmper candidate showing name + age + size. On the first accept, callsautosaveLoad(path)→Session.decode→loadSession(same code path Open session… uses).
Preference
Preferences → Backups → "Autosave session every 5 minutes (~/Documents/PyKeko-autosave/)" — enabled by default, toggle to disable. Turning it off stops the interval but leaves the recovery toast intact so old autosaves are still discoverable.
Under the hood
- New main.js IPC handlers:
pykeko:autosave,pykeko:autosave-list,pykeko:autosave-load. Path enforcement on load: must be inside the autosave dir. - Preload exposes
__moorhenControl.autosave/.autosaveList/.autosaveLoad. - New React component
MoorhenAutosaveManagermounts alongsideMoorhenControlBridgeinMainContainer.tsx. TwouseEffects: one-shot recovery detection, and the gated interval. - New Redux field:
backupSettings.enablePykekoAutosave. PreferencesList slot 52 withdefaultValue: true.
Verified
- 25 dialog-free writes → 20 files retained (retention cap works).
- Real 5L0E load → 60 s warmup → 1,157,493 bytes written;
[MoorhenAutosaveManager] wrote …log line confirms mount + successful tick. - Static-verified in the compiled bundle:
Autosave from,Recover autosave,pykeko-recover-autosaveall present.
Reinstall via the dmg to get autosave.
pk-v0.3.6 — distance save/reload + gemmi + atom-list normalisation
v0.3.6 — bundled fixes: distance-save + +-list gemmi normalisation
Two bug fixes that surfaced during the post-v0.3.5 hunt.
Distance measurements now survive .pykeko save/reload
The bug: a user carefully measures a covalent bond distance (via m-key + click or scripted distance command), saves the session, opens tomorrow → distance is gone. Every other overlay (H-bonds, salt bridges, disulfides, clashes) survived, because they dispatch to Redux vectorsSlice → session vectorData. Distance measurements bypassed Redux entirely and lived in gl.measuredAtoms — a WebGL-layer property the session serializer doesn't touch.
The fix: cmdDistance in the PyMOL translator now ALSO dispatches an addVectors action alongside the existing gl.measuredAtoms push. uniqueId prefix pykeko-distance-<name> so distances can be batch-removed distinct from other overlay categories. Vector shape mirrors the H-bond schema (dashedcylinder + labelMode: "custom" + yellow default colour). MoorhenScriptAPI.buildEnv() now exposes addVectors and removeVectorsMatchingIDString so the translator can dispatch.
Verified: 2 distance measurements → session vectorData.length === 2 after getSessionBlob; both survive a protobuf round-trip.
Not yet covered: the interactive m-key click handler in mgWebGL.tsx:5719 still writes only to gl.measuredAtoms. Interactive-drawn distances (as opposed to scripted distance ...) still lose on save. Follow-up.
gemmiAtomsForCid("//A/45/CA+CB") returned 0 atoms silently
The bug: gemmi's Selection parser accepts comma-separated atom lists (CA,CB) but not Moorhen's +-separated form (CA+CB). Coot's own parser handles both, so the mismatch showed up only when a caller used gemmiAtomsForCid for atom coordinates or metadata — most visibly, distance foo, //A/45/CA+CB, //A/50/CA returned "empty selection".
The fix: normalise the atom slot (segment 4) from + to , at the gemmi call site in gemmiAtomsForCid. Every caller works. Preserves the compound || separator; only touches atom-slot chars.
Verified: //A/481/CA+CB → 2 atoms; //A/475-490/CA+CB → 31; compound //A/481/CA+CB||//A/482/CA+CB → 4 atoms.
Nothing else changed
Same WASM as v0.3.5 (patched Moorhen matcher path, coot-side unchanged). JS-only bug fixes. Reinstall via the dmg.
PyKeko v0.3.15 — w add-water + distance label fixes
v0.3.15 — two user-reported bugs fixed
1. w (add water at pointer) no longer vanishes
Pressing w placed a water at the view centre correctly, then auto-refined it (refine_residues_using_atom_cid, weight 4000). A lone, just-placed water has no geometry restraints, so the map term drags the O atom down the density gradient — ~71 Å from the pointer in live testing — landing off-screen and disconnected from the model. The atom was added (coot's atom count went up) but it vanished from view, which read as "w does nothing."
Fix: drop the auto-refine and place the water at the pointer, matching the "Add water at pointer" label. Refinement (sphere refine / the Refine tools) stays a separate, deliberate step. Placement no longer requires an active map. Verified: water lands 0.01 Å from the pointer and stays.
2. Distance measurements no longer show a doubled label
Both interactive m-click distances and the scripted distance command drew the number twice — the native measure system's 3-decimal, unit-less X.XXX and the persistent X.XX Å label, overlapping at the pair midpoint (the smaller text underneath the main label). The persistent vector is now the single renderer; the native duplicate is gone. Clearing measurements also removes the persistent distance vectors so they aren't orphaned. Verified: single label, no duplicate, clear removes it.
Renderer commit: a92e0086 · Full changelog: CLAUDE.md
PyKeko v0.3.14 — rotation feel matches Coot/PyMOL
v0.3.14 — rotation feel matches Coot/PyMOL
Three coordinated fixes to make the viewport feel like Coot / PyMOL out of the box. User-verified visually before shipping.
1. Perspective projection is now ON by default (was OFF)
The Preferences default has always claimed `doPerspectiveProjection: true`, but the mgWebGL constructor initialised the local flag to `false`. That overrode the Redux hydration path on fresh launch and left the renderer stuck in orthographic — which is why the toggle in Preferences appeared to "not work" for many users.
2. Field of view narrowed to ~20° (was ~57°)
`mat4.perspective` FOV was set to 1.0 radian (~57°). PyMOL's default `field_of_view` is 20°; Coot uses a similarly narrow FOV. The wide 57° made edge atoms feel warped during rotation.
3. Horizontal mouse-drag direction flipped to Coot convention
The line-for-line PyMOL trackball port had rightward drag rotating the right side AWAY from the viewer. Coot's convention (and most users' muscle memory) is the opposite: rightward drag brings the right side TOWARD the viewer. Vertical drag is unchanged.
Not touched
- Mouse sensitivity slider still works the same.
- `rotationStyle` preference (`trackball` vs `gimbal`) still switches models.
- The trackball math itself is unchanged apart from the horizontal axis flip.
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.
PyKeko v0.3.13 — m-click distance pairing
v0.3.13 — m-click distances are atom-pair, not a chain
Reported this morning: with the m-click distance handler shipped in v0.3.8, clicks 3, 4, 5 kept drawing distances between consecutive clicks (2-3, 3-4, 4-5) instead of grouping into pairs.
Now: click A, click B → distance A-B drawn. Click C → nothing drawn yet (C is the start of a new pair). Click D → distance C-D drawn. Etc.
Matches the pattern the label-click branch immediately above the measure branch has always used. Fix is a one-liner: cap the current click-group at 2 atoms and open a fresh group for the next click.
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.
PyKeko v0.3.12 — loadCoords / loadMap validation
v0.3.12 — loadCoords / loadMap input validation + 0-atom guard
Same class of fix as v0.3.10 + v0.3.11, applied to the coordinate and map load entry points. Same WASM.
Fixes
- `loadCoordsFromString(null/number/'')` leaked deep iterator / `isDeleted` errors from Coot's parse path. Now: clean boundary error.
- `loadCoordsFromString('random garbage')` used to return `{molNo, atomCount:0}` — a lying success shape that also left a zombie zero-atom molecule in the state. Now: molecule is deleted and a clear error thrown: `file 'garbage' parsed to 0 atoms — is the input a valid PDB / mmCIF?`
- `loadCoordsFromURL(null)` used to resolve `fetch('null')` as a relative URL, load nothing, and report success with 0 atoms. Now: boundary error.
- `loadMapFromMtz(null/'')` used to throw the meaningless string `'undefined'`. Now: clear message.
- `loadMapFromMtz('not-b64')` used to leak atob's DOMException. Now: `invalid base64 — …`.
The 0-atom guard is the load-bearing one — MCP callers and scripts that pipe files in from disk no longer end up with silent parse failures manifesting as "why is my molecule invisible?"
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.
PyKeko v0.3.11 — saved-selection API validation
v0.3.11 — saved-selection API validation
Same class of fix as v0.3.10, applied to the other API cluster (saved selections). Same WASM.
Four leaks patched
Overnight hunt (Cycle 19) found the saved-selection endpoints leaked internal error strings and silently accepted junk:
setSelection('foo', null)used to leak"Cannot read properties of null (reading 'trim')"— the parser's own error escaped through the try/catch. Now:"Expression must be a string, got null."setSelection('foo', 42)leaked"_.trim is not a function". Now:"Expression must be a string, got number."setSelection('bar', '')returned{ok:true}and silently persisted an empty selection that matches nothing. Now:"Expression is empty. Pass a selection like 'chain A', 'resn 6ZN', or a saved-selection name."deleteSelection(null)anddeleteSelection('')returned{ok:true}even though no entry was addressed. Now: rejected with a clear error; and successful calls report{existed: true|false}so callers can tell "removed" from "wasn't there."
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.
PyKeko v0.3.10 — control API input validation
v0.3.10 — control API input validation hardening
Small JS-only follow-up to v0.3.9. Same WASM.
The nine entry points now reject bad input at the JS boundary
Overnight hunt (Cycle 18) discovered that several MoorhenControlApi methods either silently no-oped or actually hung the cootCommand queue when called with mistyped arguments:
deleteCid('', 0)anddeleteCid(42, 0)never returned — the empty/numeric CID was forwarded todelete_using_cidwhere it sat waiting forever.deleteCid(null, 0)returned{deleted: null, molNo: 0}— a lying success shape.goToResidue('')returned{centeredOn: '', molNo: 0}— same lie.deleteCid(0, cid)with reversed args returned{}silently.setActiveMap('not a number'),runPymol(42),runJs(42)all returned{}silently.
Now each throws (or, for runJs, returns {ok:false, error:…}) with a concrete message:
Error: cid: expected non-empty CID string (e.g. '//A', '//A/10', '/*/A/*/*')
Error: cid: expected string, got number
Error: setActiveMap: expected numeric mapMolNo, got string
Affected methods: deleteCid, goToResidue, refine, autoFitRotamer, flipPeptide, addTerminalResidue, runPymol, runJs, setActiveMap.
This matters mostly for MCP callers and PyMOL scripting — the error is now catchable and legible instead of manifesting as "why did nothing happen?" or "why did my session freeze?".
Also bundles the v0.3.9-tail showInteractions({types:['nonesuch']}) fix that was committed but never packaged.
Reinstall via the dmg. No new WASM, no new preferences, no schema changes.