Plan: Milestone 5 - Settings and Writing.
Done (2026-09-18)
All eight steps are implemented and verified on the web build; desktop compiles and shares every code path except the stores. Settings persist in two scopes (user:
~/.config/moonkale/settings.jsonorlocalStorage; workspace:.moonkale/settings.jsonin the folder) with environment overrides on top; the app now remembers the layout per folder, the open documents and the active one, recent folders (File → Open Recent), the provider/model/endpoint choice and policy overrides. A Settings panel (Ctrl+,, File → Settings…) edits either scope with scope badges and a JSON view, and stores API keys into the desktop secrets file. Rich markdown (Milkdown/Crepe) sits behind a Source | Rich toggle on every.mdtab, round-trips wiki-links and opens them on Ctrl+click. The agent can write:editor.replace(diff card → unsaved edit in the editor),file.create,terminal.run(output back to the model; destructive patterns always ask). An MCP endpoint (POST /mcp) gives external agents read-only tools — verified with Claude Code itself. Three new E2E suites plus the nine earlier ones pass; 56 native tests; clippy/fmt clean.
Steps as executed
| # | step | outcome | notes |
|---|---|---|---|
| 1 | ext-api::settings: SettingsFile (persisted, all optional) / Settings (resolved), overlay order, env overrides; Workspace signals + loaders/savers | ✅ 2 unit tests | ext-api.md |
| 2 | stores: desktop XDG file + secrets file, web localStorage, mobile none; workspace file through the folder source (Query::Text{path} dialect added) | ✅ 1 unit test | llm.md (secrets), project-fs.md |
| 3 | remembered: layout (workbench on_layout_change → workspace file; restored on open), open documents + active, recent folders, provider + policy | ✅ E2E settings.mjs | Reset Layout clears the saved one |
| 4 | Settings panel with user/workspace target, scope badges, JSON tab, secret store | ✅ E2E | ui.md |
| 5 | Milkdown: js/milkdown (Crepe) bundle, RichTextBackend + RichPanel, Source | Rich on .md tabs, wiki-link Ctrl+click | ✅ E2E rich.mjs | markdown.md |
| 6 | agent writes: editor.replace, file.create, terminal.run; command classification; diff/command cards | ✅ 1 unit test, E2E agent-writes.mjs | editor-agent.md |
| 7 | MCP: api::mcp (initialize, tools/list, tools/call, read-only), mounted at /mcp by the web server | ✅ curl + Claude Code (claude mcp add --transport http) | api.md |
| 8 | verify, log, vault | ✅ | this note |
What the user sees
- Launch the desktop app: the last folder reopens with the same tabs, layout and active file (or use File → Open Recent).
Ctrl+,: choose the provider (mock / Anthropic / OpenAI-compatible / Ollama), model and endpoint; pick where a change goes (this machine or this folder); paste an API key once into Store secret — it lands in~/.config/moonkale/secrets.json(mode 600), never in settings. Tick Allow mutating tools for a trusting session.- Every
.mdtab has Source | Rich. Rich mode is a WYSIWYG editor whose output is still markdown; Ctrl+click on a wiki-link (double square brackets) opens the page. - The agent proposes edits as red/green diff cards and commands as
$ …cards; Allow applies the edit to the open editor (unsaved) or runs the command in a real terminal session and hands the output back to the model. claude mcp add --transport http moonkale http://127.0.0.1:8080/mcpgives Claude Code read-only tools over the folder the web server has open.
Deviations from the plan
- No OS keychain yet:
keyringneeds a Secret Service on Linux; the secrets file (0600) plusMOONKALE_SECRET_<NAME>/ classic env variables cover the need. Recorded as the next step inllm::secrets. - Server-side providers are built from the client’s settings (
ClientMsg::{Complete, Embed}carryLlmSettings; cached per settings) — the secret is still resolved on the server, so the browser never sees a key.llm_inforeports whether the named secret exists. - Embeddings for the index follow the client’s settings via
OpenOptions { embed }passed throughopen_folderon every platform (the server function gained a parameter). - Milkdown escapes the wiki-link brackets on output; the bundle un-escapes that pattern on the way out (
unescapeWiki). Other remark reformatting (e.g.\_) is accepted as the cost of rich mode; Source stays canonical. - The markdown extension owns
.mdtabs (CodeEditorExtension::skipping(is_markdown)) and hosts the CodeMirror panel in Source mode — the panel id scheme (editor:<uuid>) is shared so tabs, closing andactive_panelbehave the same. editor.replaceis exact-match (old→new, must be unique) rather than line ranges: robust for models, and the change lands in the openDocumentso undo/dirty/save all apply and the file on disk is untouched until the user saves.terminal.runends the shell (cmd; exit $?) so the output stream closes; interactive commands would hang (no timeout on wasm yet — P-069).- MCP is the JSON-only subset of streamable HTTP (one request → one JSON response; notifications get 202; no SSE stream, no sessions). Enough for Claude Code; tool names use
_because clients validate^[a-zA-Z0-9_-]+$. - Global shortcuts now also work with nothing focused: a document-level listener forwards Ctrl-combos whose target is
body(closes P-065).
Problems hit (→ Problem Log)
- P-072 The last folder was not reopened at launch (Daniel’s first desktop test): restore ran only after a manual open. Desktop now reopens
recent_folders[0]at start (reopen_last_folder); tracing added on the restore path. Confirmed by Daniel afterwards (folder, tabs and layout come back as left). My own desktop runs had coincided with a locked KDE session (WebKit suspends invisible pages), so they were inconclusive — lesson: check the lock screen before trusting a desktop experiment. - P-069
terminal.runhas no timeout on wasm (no timer primitive without a JS dependency); output is capped at 200 KB and the shell exits after the command, but an interactive command would wait forever. Follow-up: a cancel button on the tool card. - P-070
dx servekept serving a stale JS asset afternpm run buildrewrotemilkdown.js(the content hash in the page did not change); restart dx after rebuilding a bundle. - P-071 Hydration shows server-side settings for a moment: the Settings panel’s scope badge is computed from
env_overrides(), which differs between server (has env) and wasm (none) until the first client re-render. Cosmetic; same family as P-034. - P-055 again: headless Firefox drops modifier keys on
page.mouse.click; the rich-mode suite dispatches the Ctrl+click. - The mock’s echo was widened to 1500 chars so tests (and people) can read tool results through it.
Decisions worth keeping
- Files are the settings store;
.moonkale/settings.jsonis committable and indexed like any file.SettingsFileis all-optional so a scope overrides only what it sets;versionguards the format. - Secrets are names in settings, values elsewhere (env → classic env → secrets file). The web client passes settings, never keys.
Query::Text { dialect: "path" }on the folder source resolves hidden paths without a tree walk — used for the workspace file and byWorkspace::node_at_path.- Agent writes go through the document model, never the filesystem; the user’s Save is the commit.
- External agents are read-only over MCP by policy class, not by a separate tool list.
Verified
- Web (Firefox, Playwright, mock provider, port 8090):
settings,rich,agent-writes(new) +agent,search-trace,graph,links-sqlite,ladybug,terminal,typst,lsp,session— all PASS. - MCP:
initialize/tools/list/tools/callvia curl; Claude Code (claude -pwith the server added) listed sources and searched the index correctly; aDELETEover MCP is refused. - Native: 56 tests pass; desktop builds.
What to look at on desktop
~/.config/moonkale/settings.jsonappears after the first change in Settings (user target);<folder>/.moonkale/settings.jsonafter moving a tab.- Settings → provider OpenAI-compatible, endpoint
https://api.mistral.ai/v1, modelmistral-code-latest, secret nameopenai→ Store secret with the key → the Agent header switches toopenai · mistral-code-latestwithout a restart. - Open a
.md, click Rich, edit, Save; check the file. - Ask the agent to “rename the heading in README.md to Hello” → diff card → Allow → the editor shows the change unsaved.
Deferred to Milestone 6
GPU layouts + 100k nodes (R-22), wasmtime extensions with the permissions UI (R-23), flow editor + Lux.jl (R-24), mobile shell (R-25), Postgres/Turso (R-19), TypeDB/Helix (R-26), OS keychain, a cancel for running tools (P-069), MCP over SSE/sessions if a client needs it.