Implementation ledger

This doc is the current-state ledger for the v1.0 build. It maps every feature back to its anchor in ../architecture/design.md / CLAUDE.md and shows what's done, what's in flight, and what's still pending.

Commit history is the authoritative log of what changed when. This file is the authoritative answer to where are we against the spec.

When closing a session, update the status of every row touched. When opening a session, read the In-progress and Up next sections — those are the session pickup points.


v1.0 scope at a glance

The four paramount goals from CLAUDE.md (in priority order when they conflict):

  1. Performance. Sub-frame keystroke-to-glyph (§8.2 commitments).
  2. Extensibility. WebAssembly Component Model plugin host (§5.5, §9).
  3. Strict vim modal editing with one deviation: unified command/grammar dispatch (§5.2.1).
  4. Asynchronous, multi-threaded core that never blocks the UI (§3, §5.7).

The roadmap (§13) is divided into 11 phases (Phase 0 through Phase 10). Phases 0–3 land the foundation, modal engine, terminal UI, and tree-sitter. Phases 4–10 add LSP, GPU rendering, plugin host, modes, rich buffer rendering, and v1.0 polish.

Build order: core first, plugins later

../architecture/design.md §3.1 codifies the fast-path-vs-orchestration split: features that fire per-keystroke (LSP wire + dispatch, snippet engine, picker UI, completion engine, modal grammar) live in core; opinionated tools built on top of the editor (magit clone, fuzzy file finder, git inline overlays, markdown preview, test runners) ship as plugins.

Concretely: Phases 4–6 grow the core surface. Phase 7 (plugin host) lands after, designing WIT against an exercised set of trait seams rather than speculative ones. Phase 8 / 8b build the bundled-plugin catalog on top of a stable host.

Reasons this ordering wins:

  • Performance guard. WASM-call overhead (typed call p99 < 500ns; round-trip < 5μs) is real on the keystroke hot-path. Per-keystroke subsystems amortize that cost poorly even with batching.
  • Trait surface quality. Each phase-4 feature (LspSupervisor, Picker, completion AsyncCandidateGenerator, active-snippet engine) is itself the trait surface plugins will eventually implement against. Designing WIT after we see five concrete patterns is far better than after two.
  • No sunk cost. The crates we've built so far (lattice-lsp, lattice-completion, the picker module) are correctly placed -- no migration debt is accumulating.

Phase status

v0.9.1 shipped 2026-09-23 — a fourth bundled plugin (comment), the plugin-data relocation (../architecture/plugin-data-location.md; the installer was deleting plugin state on Linux), and an honesty pass over the docs the first users read. The release failed once before it published: the .deb asset list in lattice-cli/Cargo.toml was a fifth copy of the core-plugin set and had never gained comment. A test reads CORE_PLUGINS and gates it now.

v0.9.0 shipped 2026-09-21 — the first installable release. https://github.com/dhruvasagar/lattice/releases/tag/v0.9.0. Release pipeline runs end to end; archives carry the bundled plugins in a prefix-relocatable layout. See slice-plans/launch-0.9.md for the sequencing and ../architecture/launch-0.9.md for the launch contract.

PhaseTitleStatusNotes
0Foundation✅ doneWorkspace, lattice-core, document/buffer/undo, file I/O, protocol enums
1Modal Editing✅ doneModal engine, full chord routing, motions / operators / text objects / counts / registers / marks / macros / dot-repeat (incl. insert-replay) / search (incl. hlsearch + substitute live preview) / folds / ex-commands (every command -- including :s / :g / :v via Args::List -- registered as ExCommandSpec peers, dispatched through unified grammar::execute() per §5.2.1, §B.2). Blockwise visual: per-row dispatch for d / y / c plus blockwise paste; > / < indent each line in the block; I / A enter Insert at the block's left/right column with the typed prefix replicated to every row on Esc. Every operator lands as a single undo unit -- counts on linewise ops (2dd, 2>>), block-visual rectangle ops, and I/A replications all collapse to one u.
2Terminal UI Bootstrap✅ donecrossterm + ratatui; modal cursor; mode line; gutter
3Tree-Sitter✅ done (20 languages) + Option B incremental reparseHighlights wired through a shared LangRegistry (process-wide Arc); injection callback resolves fenced ```rust ``` blocks in markdown to the rust config (and any registered language to its config) without per-document copies. Markdown is the dual-grammar split (block + inline). Grammar extension API used by builtins, not yet by plugins. New Style variants (Heading1..6, Bold, Italic, Link, Url, MarkupRaw, Markup) for precise theme targeting. Option B (B.1–B.5) lit up incremental reparse + frame-level span cache — see "Option B: incremental reparse + span cache" below. 2026-07-21 batch: expanded from 4 to 19 languages — added Bash, C, C++, CSS, Go, HTML, Java, JSON, Lua, Ruby, SQL, TOML, TypeScript, TSX, YAML. Every language ships folds.scm / symbols.scm / textobjects.scm plus per-grammar highlights.scm (via crate exports). Dotfile detection (.zshrc etc. → Bash) and mode→syntax rebuild on activation wired.
4LSP🚧 in progress (4.1–4.5 complete; 11 wire-only rows with deferred trigger UX, all post-1.0 / upstream-gated)lattice-lsp crate plus full App-side wiring across five phases. 4.1 foundation: wire + actor + handshake + utf-8/16/32 doc sync + diagnostics broadcast + supervisor + edit-dispatch + open-on-:e. 4.2 navigation: hover (K), definition family (gd/gD/gy/gI), references (gr), symbols (:lsp-symbols / :lsp-workspace-symbol w/ resolve), completion (Insert-mode shell + LSP source + docs popup + completionItem/resolve + snippets + ranking + per-language overrides + tree-sitter symbols + path source + commit chars + ghost text + cross-source dedup). 4.3 edits: formatting + rangeFormatting + onTypeFormatting; signatureHelp + Insert autopilot; rename + prepareRename; codeAction + resolve + executeCommand; willSave + willSaveWaitUntil (format-on-save) + didSave; workspace/applyEdit inbound channel. 4.4 polish: window/showMessage + showMessageRequest + showDocument, $/progress + modeline + cancel, :lsp-restart, documentHighlight overlay, selectionRange + :lsp-expand/shrink-region, foldingRange + FoldMethod::Lsp + lsp-folding-mode, inlayHint virtual-text overlay, semanticTokens/full + /full/delta overlay, textDocument/diagnostic pull, workspace/didChangeConfiguration fan-out from typed-option cascade, workspace/didChangeWatchedFiles (notify watcher + globset matcher), workspace/didCreateFiles, dynamic registerCapability / unregisterCapability two-way index, lsp.log_level / lsp.log_capacity TOML. 4.5 expansion (wire + host complete): call hierarchy (:lsp-incoming-calls / :lsp-outgoing-calls), type hierarchy (:lsp-supertypes / :lsp-subtypes), documentLink + resolve (gx), codeLens + resolve (:lsp-code-lens), documentColor + colorPresentation (:lsp-color-presentation), moniker (:lsp-moniker). 11 rows ship the wire wrapper + capability probe with the trigger strong-reason deferred: the five file-op hooks (willCreateFiles / willRenameFiles / didRenameFiles / willDeleteFiles / didDeleteFiles — need editor-driven file-op UX + a unified pre-save blocking-edits pipeline), linkedEditingRange (needs multi-cursor shadow-edit machinery), inlineValue (needs DAP), inlineCompletion (lsp-types proposed flag), inlayHint/resolve (needs an inlay-interaction UX), workspace/diagnostic (closed-file pull — needs :diagnostics reworked for workspace scope), and semanticTokens/range (deliberately not adopted — mutually exclusive with the shipped full+delta path). All multi-result LSP lookups + :diagnostics route through one vertico picker. Cancellation tokens plumbed through every wrapper. M.6 sub-mode cascade: 15 sub-modes (completion, diagnostics, hover, signature, format, rename, symbols, code-action, nav, progress, document-highlight, selection-range, folding, inlay-hint, semantic-tokens) toggle individually or via the lsp-mode umbrella. Per-feature matrix in ../notes/lsp-features.md.
5GPU Rendering Foundation🟡 in progress (5.0–5.4 ✅; 5.B ✅; 5.5.A–G.final ✅; 5.5.H first sweep ✅ (12 dead App-side delegates retired + 2 marked allow(dead_code) for test-only callers); 5.5.LSP.1–5 ✅ (10 LSP request arms migrated host-side: hover / definition / declaration / typeDef / impl / references / signature-help / completion / documentSymbol / workspaceSymbol; drains + picker + jump machinery stay App-side; FollowLink deferred — cross-cuts do_edit / open_external_uri); 5.5.SNIPPET.1 ✅ (<C-x><C-s> snippet expand migrated host-side: do_snippet_expand_at_cursor + expand_snippet + snippet_variable_context + active_language_idEditor; App-side keeps 1-line delegates for expand_snippet_with_lsp_edits / picker accept / completion accept callers; Effect::SnippetExpand routes through editor.do_snippet_expand_at_cursor()); 5.5.G.23 ✅ keystone — every operator / motion / text-object / ex-command keystroke now resolves host-side: 4 commits (foundation: DispatchOutcome.effects + foldenable / fold_start_at[_any] / snap_cursor_past_closed_folds / dispatch_blocking / effect_mutates[_or_yanks] / is_blank_lineEditor; split: App::apply_effect factored into wrapper + apply_effect_app_arms so the dispatch loop can drain host-emitted effects without double-running handle_effect; runners: run_oil_invocation / run_help_invocation / run_file_tree_invocation / run_read_only_motionEditor; invoke: run_invocation + run_document_invocation + apply_effect_host recursive Effect::Many flatten → Editor, Action::Invoke(inv) wired to editor.run_invocation); pre_active / pre_cursor snapshot moved before editor.dispatch so the State-A hover-popup auto-dismiss hook still sees pre-motion cursor; macros / RepeatLastChange / FindRepeat / Completion family / CommandLine cluster / Picker accept-dismiss / FollowLink / OilNavigateUp / Insert remain App-side pending their own helper chains; 5.6+ open); 5.5.G.23.insert-prep ✅ (signature_help_trigger_chars + on_type_formatting_trigger_chars + dedup_rendered_by_textEditor); 5.5.G.23.next-actions ✅ (DispatchOutcome.next_actions: Vec<Action> channel + new Action::LspOnTypeFormattingRequest(char) / Action::LspInsertCompletionRequest variants for host→App LSP autopilot follow-ups; renderer drains via self.apply(action) with should_quit short-circuit); 5.5.G.23.insert ✅ (Action::Insert(s) host-handled via editor.do_insert_text — full Insert-mode keystroke path: buffer edit + dot-repeat recording + block-visual edit accounting + insert-completion live-refresh + SignatureHelp / OnTypeFormatting autopilots; LSP follow-ups defer through out.next_actions; maybe_refresh_insert_completion_after_edit migrated host-side, emits LspInsertCompletionRequest on isIncomplete); 5.5.G.23.macros ✅ (Action::PlayMacro / PlayLastMacro / RepeatLastChange / FindRepeat host-handled — do_play_macro pushes recorded actions through next_actions with should_quit short-circuit; dot-repeat re-dispatches through host's run_invocation + replays the captured Insert tail through host's do_insert_text; find-repeat synthesises a CommandInvocation; bug-fix: Effect::EnterMode(mode) added to host's handle_effect so post-run_invocation modal checks see the flipped state synchronously); 5.5.G.23.cmdline-prep ✅ (cmdline completion helpers: compute_completion_state + refresh_completion_popup + open_completion_popup + completion_auto_insert_single + CompletionComputeError + prefer_aliases_for_command_candidates + subsequence_match_rangesEditor / host fns); 5.5.G.23.cmdline ✅ (every :-line keystroke now resolves host-side: 8-arm cluster — Append / Backspace / Submit / Clear / DeleteWordBackward / DescribeUnderCursor / AppendChord / CompleteOrAdvance — host-handled via editor.do_command_line_* methods; execute_ex_line + try_resolve_missing_arg_prompt + chord_capture_active + refresh_substitute_preview + collect_substitute_matches_for_line + delete_trailing_word + COMMAND_HISTORY_CAP + MissingArgPrompt all migrated; Submit dispatches through host's execute_ex_line which routes ex-command effects via apply_effect_host). App::apply remaining (13 explicit arms, all renderer-coupled): 2 host-emit landing zones (LspOnTypeFormattingRequest + LspInsertCompletionRequest — intentionally App-side as the host→renderer LSP autopilot channel); 8 Completion arms (CompletionTrigger / Next / Prev / Accept / ToggleDocs / FilterToSource / FilterClear / AcceptThenInsert — deep deps on populate_insert_completion_sync / lsp_completion_meta_for / refilter_insert_completion / effective_completion_for / effective_commit_chars_for / do_completion_resolve_focused + LSP async machinery); 2 Picker arms (PickerAccept / PickerDismiss — file-open + SMR queue + activate_buffer chain); 1 LspFollowLinkAtCursor (cross-cuts do_edit + open_external_uri); 1 FollowLink (BufferKind-dispatched into 4 kind-specific helpers); 1 OilNavigateUp (do_open_oil chain). Each pulls in 2–5 App-side helpers with their own LSP-async / file-open / popup chains; estimate 5–10 more focused commit chains to genuinely empty App::apply. 5.5.G.24 ✅ AppEffect router collapse: Editor::apply_app_effect(app, out) in lattice-host::dispatch mutates editor fields directly for AbsorbOperatorPrefix (only structural arm — pending_countop_count, partial_chord.extend(prefix) via host-side keymap_normal::operator_prefix) and pushes a follow-up Action into out.next_actions for every other variant; Effect::AppAction(app) wired into host's handle_effect; App-side apply_app_effect retired entirely; Effect::AppAction(_) in apply_effect_app_arms collapses to grouped no-op. The unified deferred-Action channel (next_actions) now handles macro replay + dot-repeat + AppEffect-derived follow-ups under a single drain in the renderer's apply wrapper. 5.5.H sweep ✅ (reframed): the 13 remaining App::apply arms are documented in App::apply as the intentional architectural seam, not migration debt — every one is a renderer-coupled landing zone (LSP autopilot, completion popup + LSP async resolve, picker SMR + file-open, follow-link / external-uri / BufferKind-dispatch). The lattice-ui-gpui peer renderer will implement its own equivalents on its own App-equivalent against its own LSP runtime / window-open / picker UI. Dispatch surface is gpui-ready. 5.6 ✅ pane-provider mode-walk lookup → host: new lattice_host::pane_render module with trait ProviderLookup { fn has_provider(&self, mode: ModeId) -> bool; } + pub fn resolve_pane_render_mode<L: ProviderLookup>(editor, buffer_id, lookup) -> Option<ModeId> — host owns the algorithm (walk active minors most-recently-activated first, then major); lattice-ui-tui::pane_render::PaneRenderRegistry implements ProviderLookup (single contains_key probe, no v-table on the paint hot path); App::pane_render_provider collapses to two lines (resolve mode host-side, fetch TUI-typed provider from registry). A future lattice-ui-gpui ships its own typed registry + ProviderLookup impl and reuses the host walk verbatim. 5.7.A ✅ GPUI peer-renderer scaffold: new lattice-ui-gpui crate with GpuiTheme (stub theme cache), GpuiPaneRenderRegistry (impl lattice_host::pane_render::ProviderLookup via HashSet probe), GpuiRenderer (impl lattice_host::Renderer — surfaces renderer-specific assoc types Theme / PaneRenderRegistry to the host's renderer-trait machinery), GpuiApp { editor, theme, pane_render_registry } (peer composition root in the same shape as lattice-ui-tui::app::App). gpui = "0.2.2" dep gated behind a window Cargo feature; scaffold lib + tests build everywhere (incl. headless CI / WSL2 without display libs). Placeholder window binary (src/bin/lattice_gpui.rs, required-features = ["window"]) opens a 720x480 native window with "Lattice (GPUI) — 5.7 scaffold" centred text — opt-in for hosts with libxcb1-dev / libxkbcommon-dev / libxkbcommon-x11-dev installed; verified to compile under --features window. The host-substrate decoupling claim is now provable: lattice-ui-gpui depends only on lattice-host + lattice-core + lattice-mode, never on lattice-ui-tui. Boot logic (LSP subsystem, command/mode/snippet registries, event bus) is renderer-neutral and slated to migrate to Editor::boot in a future slice; until then GpuiApp::new uses Editor::default. 5.7.B (real input dispatch + paint wiring) + 5.8+ feature parity + 5.9 CLI renderer-select flags follow. Tests: 185 host + 1419 ui-tui + 1 ui-gpui = 1605 greenPhase 5 splits the host out of lattice-ui-tui so GPUI can land as a peer renderer. Plan: ../archive/phase-5-extraction.md. Status: ~24k LoC migrated from lattice-ui-tui to lattice-host/lattice-core. 5.0 audit ✅, 5.1 empty crate ✅, 5.2 HOST modules ✅ (keymap catalogs closed alongside 5.4), 5.3 renderer-neutral theme ✅, 5.4 chord + input + keymap-family split ✅ (5 commits — leaf translators, normal-mode dispatch, keymap_normal, keymap_{insert,visual,replace}, input.rs entry point). 5.B App→Editor composition (../archive/phase-5b-app-design.md) ✅: pivoted from Option D (generics) to Option E (composition) in 5.B.3; clusters 1–12 from the plan shipped across 5.B.4 → 5.B.19 plus a C1–C5 wave (cmdline completion, visible-highlights, popup snapshot, pane_tree, document/snapshot_cache, folds, LSP file watcher); final tail (completion cluster #8 remainder + popup back-stack + pending_config_structural_sections) closed in 5.B.20. App shrank from ~6k → 4,040 LoC and now holds just four fields (editor, pane_render_registry, theme, lsp_file_watcher); Editor is 822 LoC. Tests: 1424 ui-tui + 177 host + 180 lsp = 1781. Remaining (post-5.4 plan revision): 5.5 dispatch extraction (App::applyEditor::dispatch, the last big architectural unlock for parallel GPUI work — focused design doc ../archive/phase-5-dispatch-extraction.md), 5.6 pane-provider lookup → host (small mechanical move), 5.7 lattice-ui-gpui scaffold, 5.8+ GPUI feature parity, 5.9 CLI renderer-select flags. The old 5.5 (trait-objected PaneRenderer) and 5.6 (lattice-render crate) are dropped — both were artefacts of the pre-Option-E App<R> plan that the 5.B composition pivot retired.
6Document Renderer + UI Components✅ done (delivered across Phases 4–5)Scope shipped incrementally under Phases 4–5, not as a discrete phase. Popup system ✅ — popup-unification initiative complete; completion / hover / signature / diagnostic / code-action all compose through the shared pane path (no bespoke text layout). Picker primitive ✅ — lattice-picker crate; file picker + every LSP multi-result list (PickerSource::*) with async syntax-highlighted preview. Status lines + segment registry ✅ — modeline element/descriptor registry (ML.0–ML.5) across both renderers; header line = multibuffer view-header virtual rows. Buffer-backed views ✅ — file-tree, oil, :diagnostics, help, *messages*, *lsp* all Documents (everything-is-a-buffer). Command line / echo area ✅ — rich minibuffer + completion popups + substitute live-preview. Notifications ✅ — lattice-notify (NOTIF.1a data layer + store/expiry, 1b/c corner-anchored rendering in BOTH peers, 1d magit remote ops as first consumer, 1e notifications.* config + the *messages* tee; NC.1–NC.6 icons, a success level, repository scope, unambiguous task text and stopped states — slice plan slice-plans/archive/notification-clarity.md); design ../architecture/notifications.md. The three surfaces answer three different questions and so do not compete: headerline = "what is this buffer doing", *messages* = "what happened earlier", notification = "the thing I started has finished, wherever I am now". Plan change: the lattice-render-document (taffy + cosmic-text) crate was superseded by the GPUI peer renderer (Phase 5.7) — document layout/text lives in lattice-ui-tui (ratatui) + lattice-ui-gpui (GPUI). No known gaps. (The "only gap: a dedicated notification system" line stood here until 2026-08-19; it predated NOTIF.1 and was stale, not true.) Popups, pickers, panels-as-buffers detailed in design §5.9.
7Plugin Host✅ done (PH7.0–PH7.12; host runtime — editor-side loading is Phase 8)lattice-plugin-host crate: wasmtime 46 + Component Model + WASI preview 2; the wit/ API package; the capability/WASI security model + fuel + epoch deadlines + crash-quarantine (PluginCrashed); every extension seam mirroring an exercised native trait — picker-source (⭐ exit), grammar (the one synchronous-on-keystroke seam, sync trampoline), completion-source (generator), events/hooks, decorations (producer; renderer read-from-cache is Phase-8 boot-wiring), config, modes, host-services (walk); the fuzzy-finder validation plugin (parity + overhead-benched, not cut over); CI overhead gates (typed-call / round-trip p99, no-per-frame-WASM dep-graph guard, wasm32-wasip2 in CI); registry/keymap/config teardown + graceful-degradation audit. Editor-side loader + manager + observability landed (Phase 8, PL8.A–H + PO.1–5, branch phase-8-plugin-loader): lattice-host depends on lattice-plugin-host transitively via the lattice-plugin-loader crate (cli → host → plugin-loader → plugin-host → wasmtime; the renderer no-per-frame-WASM guard still holds — it checks renderer deps). The loader stands the host up at boot (PluginLoaderHandle service) and drives compile → instantiate → activate. Shipped: on-disk discovery + the seam→registry drain (PL8.B), the :plugin-load / :plugin-unload / :plugin-reload ex-commands (PL8.C), the init.rs config path + auto-reload watcher (PL8.D), and the buffer-backed :plugins manager view with reload/unload/describe/refresh/trace/trace-level chords (PL8.H). Observability (PO.1–PO.5): the PluginTracer boundary-trace substrate + PluginTracePushed event (PO.1); the async seams instrumented (PO.2); the gated hot-path grammar seam — a per-plugin atomic HotGate, zero-cost when off, benched at ≈+1 ns (PO.3); the *plugin-trace* / *plugin-trace:<name>* buffer views + :plugin-trace + the manager t/T drill-in + the live typed plugin.trace-level option (PO.4); and the wasi:logging-shaped guest logging import across all 8 async worlds, Layer 2 (PO.5). Security hardening: the untrusted manifest.id is validated to a single safe path component before it keys the writable data mount. User-reachable: :plugins, :plugin-load/unload/reload, :plugin-trace, :reload-config, :list-plugins / :describe-plugin, and the :describe-plugin-api / :list-plugin-apis / :export-plugin-api catalog. Remaining: bundled reference plugins (8b) + modes-as-components. Slice plans: slice-plans/archive/plugin-host.md, slice-plans/archive/plugin-loader.md, slice-plans/archive/plugin-observability.md; design fragments: ../architecture/plugin-host.md, ../architecture/plugin-observability.md.
8Major/Minor Modes + Reference Plugins🟡 system built natively; as-components repackaging awaits Phase 7The mode/view system is built and heavily exercised: lattice-mode registry with the major/minor axes, conflict resolution, target_buffer_kind, lifecycle on_activate/on_deactivate, the 15-member LSP sub-mode cascade, plus help / diff / oil / file-tree / multibuffer modes. Phase 8's design exit ("mode and view systems fully exercised; everything-is-a-buffer validated") is met natively. What remains is the §5.8.3 goal of shipping major/minor modes as WASM components — blocked on Phase 7 (no plugin host yet). Built-in modes stay native by design (the default keymap/grammar never crosses the WASM boundary); the as-components path is for the extension story.
8bBundled + reference plugins🟡 three plugins shipped (auto-pair, treesitter-context, org); the rest of the curated set unbuiltCurated set of first-party WASM Component plugins shipping with the editor binary -- LSP server manager (lighthouse), plugin manager, fuzzy-finder, project grep, git client, snippet engine, editing helpers, diff viewer, outline sidebar, format-on-save, test runner, markdown preview. Each crate lives at plugins/<name>/ (fuzzy-finder ships; the rest not built). See ../architecture/design.md §5.5.6 for the strategy + the seven WIT prerequisites Phase 7 must expose. Design landed (2026-07, branch phase-8-plugin-loader): the first-wave fragments — plugin-auto-pair.md (the trivial-first plugin: auto + manual pairing), lighthouse.md (LSP server manager + its net/proc/task/register-server host-services extension), and — promoted to v1plugin-treesitter-seam.md (the foundational plugin↔tree-sitter query seam that unlocks structural plugins) — each with a slice plan under slice-plans/. auto-pair surfaced two more general host prereqs (a grammar-context document handle; declining/fall-through bindings). BUILT (2026-07-20, phase-8-plugin-loader): the auto-pair epic is complete (AP.0.1→AP.4) and it is the FIRST plugin shipped — as a core plugin, not compiled-in. Delivered: the grammar document handle (AP.0.1) + fall-through/Effect::Declined (AP.0.2); the tree-sitter query seam TS.1–TS.3 (plugin-treesitter-seam.mdtree-snapshot/node/query/tree-cursor, host-side predicate eval, the enclosing scope query at ~1.1 µs, capability-gated); auto-pair's auto + manual styles (AP.1–AP.3, manual = TS.3's first structural consumer); and the plugin-manager redesign core track PM.1–PM.4 (plugin-manager.md): a runtime-root search path + boot discovery, cargo xtask build-core-plugins staging, and the manifest default_mode + <id>.enabled config gate — so auto-pair auto-pairs out of the box (no include_bytes, no toolchain, no init.rs). Remaining plugin-manager work = the user track PM.5–PM.8 (use-package require + build-on-boot from git/local sources). Note: much of the other bundled functionality already exists natively (fuzzy-finder = lattice-picker, project grep, git diff = lattice-diff, snippet engine = lattice-snippet, outline = :lsp-symbols, plus lattice-claude-code) — none yet repackaged as WASM Component plugins. The org plugin (2026-08-24→25, lattice-org-plugin) is the second reference plugin and the deepest test of the seam surface so far: seven seams from one composed world, four modes, and a whole editing grammar — outliner, TODO workflow, checkboxes, timestamps, links, tables, and the agenda — with zero Editor:: methods, zero host Action variants, no BufferKind::Org and no Lang arm. The three host changes it needed are all generic (a language index on ModeRegistry, mode-declaration.target-language, and lifting the majors restriction off the modes seam); the agenda-source seam it added last is generic in the same way — the guest declares its file extensions, so the host never learns what a .org file is. See the org-mode section below.
9Rich Buffer Rendering⛔ RETIRED from v1 (2026-08-18)Retired, not deferred-by-drift. Its named crate (lattice-render-editor) was superseded by the cell grid; most of the intent shipped under Phases 3–6 (markdown grammar + injections, the full Heading1..6/Bold/Italic/Link/Url/MarkupRaw style set, rich minibuffer, virtual rows, soft-wrap, indent guides); what remains is the variable row-height scroll machinery (Fenwick index + every uniform-row scroll/cursor calculation), which is GPUI-only since a terminal has one cell size — a permanently renderer-divergent surface for a cosmetic gain. Moved to Post-1.0 with Path 4, which shares the substrate. Carved out and kept for v1: concealment (hide ** / []() / heading # off the cursor line) — no shaped path needed, identical on both peers, not yet implemented. See design.md §13 Phase 9.
10Polish + v1.0🟡 partial (themes ✅, packaging in progress)Themes shipped (lattice-theme crate, theme-system slice plan, themeable popups/modeline). Packaging in progress — the tag-driven release pipeline (.github/workflows/release.yml, design release-pipeline.md) builds cross-platform archives + Linux bundles. Still not started: *scratch:rust* live-eval workflow (§10, depends on Phase 7 plugin host), accessibility, crash reporter.

Active focus: Phase 8 (Plugin manager) — editor-side loading landed on branch phase-8-plugin-loader: the lattice-plugin-loader crate + on-disk discovery + the :plugin-load/unload/reload commands + the init.rs config path + the buffer-backed :plugins manager view (PL8.A–H), plus the full plugin-observability stack (PO.1–PO.5: the boundary tracer, the gated hot-path grammar seam, the *plugin-trace* buffer views, the live plugin.trace-level option, and wasi:logging guest logging). Phase 7 (Plugin Host) complete (PH7.0–PH7.12). Phase 4 (LSP) wind-down complete at the wire+host layer (4.1–4.5 ✅; 11 wire-only rows deferred post-1.0 / upstream); Phase 5.8 GPUI landed the peer renderer. First bundled plugin shipped (2026-07-20): the auto-pair epic (AP.0.1→AP.4) is complete — auto-pair is the first core plugin, prebuilt + discovered + on by default, built on the new tree-sitter query seam (TS.1–TS.3) and the plugin-manager core track (PM.1–PM.4). Remaining Phase-8 work: the plugin-manager user track (PM.5–PM.8 — use-package require + build-on-boot), more core plugins, and repackaging the built-in modes as WASM components.

Gutter signs (2026-09-08, SG.1–SG.4b landed). A generic sign mechanism — vim's :sign define / :sign place, with the host owning what a sign IS and no built-in opinion about what any sign MEANS, so a provider's marks, a plugin's breakpoints and a future built-in all place through one registry and are styled through the ordinary theme registry. A placed sign shares the gutter's mark cell with diagnostics and priority resolves the contention (strictly-greater, so a tie leaves the error visible); giving signs their own column would have cost every buffer a column of content forever for a mechanism most buffers never use. Design: ../architecture/gutter-signs.md; slice plan slice-plans/archive/gutter-signs.md.

A plugin both declares its signs (wit/signs.wit, the theme.wit shape: auto-namespaced by plugin id, drained once at load, reversed on unload) and places them by name, with the host interning the name to a SignId at the boundary — off the render path, which is what lets a placement stay Copy with no per-line String. A name nothing has defined is skipped rather than failing the producer's batch, so one unregistered sign cannot take a plugin's diff and severity marks down with it.

SG.4 completed the unification: diagnostics and diff marks ARE signs, registered at boot into the same registry, and GutterDecoration has one variant. The host owns no hardcoded gutter semantics — neither renderer can tell a diagnostic from a hunk mark from a plugin's breakpoint. SignDefinition.column is what made it safe: collapsing both columns into one contended cell would have dropped the git gutter on exactly the lines a diagnostic touches, so contention is per-column and the host owns only the column ORDER. A user-configurable column list stays open — it changes the gutter's width, which every scroll, wrap and cursor-column calculation reads.

Indentation guides (2026-08-16, IG.0–IG.6 landed; branch dhruva/indent-guides). A vertical rule down the whitespace at each level of indentation, with the block enclosing the cursor drawn brighter, in both renderer peers. Design: ../architecture/indent-guides.md; slice plan slice-plans/archive/indent-guides.md.

The load-bearing choice is where the layer is produced. It is built per pane, beside that pane's DisplayMatrix, in the same cells_worker pass — which is what buys every visible pane coverage (overlay_worker is active-pane only, so a split would have shown guides on one side), keeps the active-block highlight off the worker entirely (extents are published; each renderer picks the active one per frame from the cursor row it already holds, at 92 ns), and leaves exactly one producer of the predicate deciding whether a column may be painted — so neither peer re-derives it and the two cannot drift. Baking guide glyphs into DisplayLine.text, the obvious move since whitespace markers work that way, is rejected in the fragment: it forces the terminal glyph onto the GPU peer, makes the active guide a cursor-coupled matrix rebuild, and breaks with_source_line reuse because a row's guides depend on its neighbours.

Two things fell out of the build. The TUI pass had to move after the horizontal clip: clip_spans_horizontally measures in bytes (its own comment calls the display-width model a punt), so a three-byte glyph substituted ahead of it shortens every indented line by two columns per guide. And IG.5 migrated foldmethod=indent onto the shared walk, which closed a real bug — leading_indent counted a tab as one column, so tab-indented files folded at different boundaries than their space-indented twins. That guarantee ("zc and the active guide agree about this block") was approximate before and is now exact.

Worker cost is flat at ~23 µs across a 16× file-size range for a fixed 250-row window. The window is not always fixed, and that qualifier hid two defects: below cells_worker::WINDOW_CAP_LINES (2048) the display matrix covers the whole document, so on a normal source file the guide layer is rebuilt over every line on every keystroke. Both fixed 2026-08-18 after the keystroke→glyph ratchet flagged the first:

  • The covered range was read one line at a time, one O(log n) rope descent per line. Buffer::line_shapes_from streams it from a single cursor instead — one descent per build.
  • Per-row resolution was O(covered lines × blocks): every covered line tested every block in the window. It is now a forward cursor plus an active set whose length is the nesting depth, so per-line cost is flat at ~93 ns.

Together, on 2 000 lines of nested source (debug, the shape real code has), 14.69 ms → 2.10 ms p50 per keystroke. The 14.69 ms was a paramount-goal-#1 violation no gate was watching, because the ratchet's only corpus was flat — every line at column 0, therefore no blocks, therefore nothing to resolve. keystroke_to_glyph_nested_within_baseline closes that blind spot. CI still skips keystroke_to_glyph_is_flat_across_file_size: it passes on a quiet machine (spread 2.1× against a 2.5× tolerance) but that margin is thinner than a shared runner's variance.

See benchmarks.md § IG.6.

Auto-indent (2026-08-15/16, IN.0–IN.11 landed; branch dhruva/auto-indent). Nothing indented before this: o / O / <CR> spliced a bare newline at column 0, > / < / <C-t> / <C-d> used a hardcoded four spaces, shiftwidth / expandtab did not exist, there was no = operator, and lattice-lsp's implemented formatting / rangeFormatting were reachable only through :lsp-format. Design: ../architecture/auto-indent.md; slice plan slice-plans/auto-indent.md.

What shipped. Seven typed options (shiftwidth, expandtab, indentmethod, electricindent, equalprg, formatprg, formatonsave) with IndentUnit in lattice-core; a lattice-syntax::indent engine — Helix-dialect indents.scm evaluator plus a lexical bridge — with queries for 17 of 19 bundled languages; predictive indent on <CR> / o / O; electric reindent on closing tokens; the = operator; and the lattice-format crate behind :format and format-on-save. Per-language conventions (Go's tabs, YAML's spaces, the prettier family's 2) ride Mode::options().

The design's load-bearing claim is that "auto-indent" is three features with three latency budgets, and the budget dictates the source. Predictive and electric indent sit on the keystroke path, so neither an LSP round-trip nor a process spawn is available; reindenting existing text is user-initiated, and that is where external tools belong. = therefore stays vim's indent-only operator and is deliberately NOT formatter-backed — an operator whose effect is unbounded rewriting cannot compose with motions.

Three findings changed the design mid-build, each recorded in the fragment:

  1. Incomplete code defeats the tree entirely. Tree-sitter renders half-typed code as a bare ERROR node with no block structure, so the engine answers zero for a cursor plainly inside a function. It now declines and the lexical bridge answers. Consequence: the tree path does not win while typing — it wins on complete code (=, <CR> inside written code, string/comment awareness).
  2. The bench deleted a branch. A synchronous "catch-up" reparse for stale snapshots measured 7.6 ms at 64 KB / 15.4 ms at 129 KB and would have returned None anyway (see 1). Removed; benchmarks.md §IN.2 keeps the numbers as a negative result so it is not re-added.
  3. A perf bug of a shape this repo has hit before. The indent query first ran over the whole file (2.57 ms at 3200 lines), then its scope lookup used a linear sibling scan — the same 0..child_count walk benchmarks.md records under TS.1. Now flat at ~9.5 µs.

Open, so the plan is NOT archived: equalprg (⛔ needs a range-filter seam that does not exist; IndentResolver answers "how deep is line N", not "rewrite this text") and IN.10 LSP onTypeFormatting (⛔ dropped, not deferred — same wall as electric reindent plus an async round-trip and a protocol that permits document-wide edits). sql and markdown ship no indents.scm by decision.

Tree-sitter context (2026-08-16/17, ✅ TC.1–TC.7 landed; branch dhruva/treesitter-context). Sticky scope headers — the enclosing impl / fn pinned above the text after their own lines have scrolled away, the nvim-treesitter-context / sticky-scroll idea. Ships as a core bundled plugin, the second after auto-pair, consuming the ✅ tree-sitter seam (TS.1–TS.3). Design: ../architecture/treesitter-context.md; slice plan slice-plans/archive/treesitter-context.md.

The design's load-bearing claim is that a scope — a structural range plus its header span — is a pure function of the parse tree, so it is correct to compute once per parse in the guest and resolve against an anchor line afterwards in the host. A resolved row list is not: it is a function of the cursor and would thrash at keystroke rate. That split is what keeps WASM off the scroll path, makes the scroll model's reservation incapable of disagreeing with what is painted (the host produces the line list it reserves for), and preserves syntax colour by construction — the cells worker builds context rows through the same cell-builder as document rows, so there is one derivation rather than two that can drift.

Two host seams landed with it, and they were the real cost, the lighthouse shape: a context producer seam (the decorations async-producer pattern) and the theme element-registration seam, which closes the deferred WIT item in ../architecture/theme-system.md. Both are general — every later plugin gets them. The layer is keyed by pane_id, not buffer_id, which is a first: one buffer in two splits with cursors in different scopes must show different context, and no existing per-pane layer needed that.

What building it corrected. The design's resolver predicate was wrong (header_end < anchor is "above the cursor", not "scrolled off" — it needed viewport_top), its complexity claim was wrong (O(n + d log d), measured 204 ns / 2.66 µs / 21.8 µs at 100 / 5k / 50k scopes), and one designed ex-command could not be built: apply-ex-command gets no tree handle, so :context-up was dropped rather than shipped as a silent no-op — the chord is the jump's only surface. treesitter-context is also the first component to lend a host resource across an ASYNC guest suspension (borrow<tree-snapshot>), which was an open question until the fixture proved it; had it failed, the fallback was a host import handing out owned snapshots by buffer id, widening the tree-sitter capability from "the tree you were handed" to "any buffer's tree, any time".

Multibuffer-provider review + follow-on work (2026-08-10 → 08-12). A review of the pending provider catalogue (slice-plans/multibuffer-providers.md) found it stale — A.6 had already shipped (the error-list substrate plus the *problems* view IS the editable quickfix), A.16 was subsumed by it, A.7 largely covered by lattice-magit, and the dependency blockers cited for A.1 / A.4 had dissolved (lattice-vcs / lattice-diff / lattice-lsp are all standalone crates now). Five plans landed out of that review; only ONE is a new provider, because the gaps turned out to be mostly substrate.

PlanSlicesOutcome
refreshable-views.mdRV.1–3 ✅gr is ONE shared chord over Mode::refresh_action(). Five modes had copied it; *problems* and narrow had none.
error-list-producers.mdEP.1–6 ✅Per-source slices; the language server and references both feed the error list, each opt-in-gated.
lsp-references-view.mdLR.1–3, LR.5 ✅:lsp-references opens an editable references multibuffer (catalogue A.3); <C-q> sends any picker's filtered results to the error list.
multibuffer-stale-sources.mdSS.1–3 ✅Data-loss fix: :w on a multibuffer silently overwrote source files that had changed on disk.
diff-refinement.mdDR.1–4 ✅Word-level intra-line diff highlighting — which part of a changed line changed.

Two catalogue entries were struck rather than built: A.4 DiagnosticsProvider (making the language server an ErrorList producer yields the grouped view, the picker and the whole :next-error family at once, beside a *problems* view that already exists) and LR.4 (specified as a references-only version-skew guard; reading the save path showed it was data loss across three providers, so it became the SS plan).

Two bugs surfaced that were on no plan and mattered more than the features around them: the multibuffer save above, and magit diffs losing syntax highlighting on gr — two code paths built the same buffer and only one highlighted. A third, 9e46c270, was pure build hygiene: a stale build-script reference to a deleted plugin dirtied lattice-plugin-host on every build, relinking 25 test binaries; cached cargo test -p lattice-host went 445s → 185s.

Cursor visibility + line-count spaces (2026-08-13, live report). G on a 219-line file puts the cursor on 219 while the last drawn row is 218, and the same file renders a phantom empty line 220. Two separate defects, tracked apart in cursor-visibility.md precisely because fixing the second can mask the first. The phantom line is a coordinate-space leak — Buffer::line_count returns ropey's raw count by design and 123 call sites each re-derive the correction, which is why it has been "fixed" more than once; the plan renames the raw accessor so the compiler forces the choice at every site. Nothing implemented; the plan opens with the one measurement that discriminates between its two hypotheses, because every confident diagnosis in the session that found it was wrong and every instrumented one was right.

Still open from this review: PD.4–5 (magit-project-diff.md, catalogue A.1 — edit propagation and file folds; PD.1–3 have landed); PV.2–3 (migrating :search and :copen onto the provider-view seam PD.3 built — see below); DR.5, region-to-region diff refinement; and lattice-ui-tui's ~80s chrome_rows_composes_with_arbitrary_splits_and_terminal_sizes, which makes a full-workspace gate unrunnable on a loaded machine.

The provider-view seam (PV.1, 2026-08-12). PD.3's acid test — "a provider crate adds its view with zero host changes" — could not be met by the shape search, problems and narrow all use: a per-provider AppEffect variant, a host dispatch arm, and a plugin-boundary arm, three crates for the N+1th provider. Rather than spend a fourth variant and call the test passed, PD.3 built the generic seam first (lattice_mode::ProviderViewRegistry + one AppEffect::OpenProviderView + one host arm) and became its first consumer. multibuffer-views.md §3.7a carries the design; PV.2 / PV.3 migrate search and problems, each after promoting one host fact to a service. :narrow deliberately stays on its typed variants — it resolves a range against cursor / Visual / marks, which is not a provider parameter, and making it fit would export editor state through ModeActivator.

That slice also un-gated a latent bug: MultibufferExcerptsReady and its off-keystroke wake lived inside lattice-multibuffer's feature-gated providers::search module, so a --no-default-features build — and any provider outside that crate — appended excerpts that appeared only on the next keypress.

DR.5, and a wrong claim corrected. The refinement gap above was recorded as wanting "similarity-scored pairing". Reading magit shows that is not what it does: magit-diff-update-hunk-refinement hands the hunk's whole removed region and whole added region to smerge-refine-regions, which word-diffs the two concatenated texts — unbalanced hunks fall out of that naturally. refine.rs's comment claiming "Magit declines the same case" is therefore false. The fix is region-to-region refinement, which subsumes the equal-length case rather than special-casing it.

Provider-home reversal (2026-08-11). The 2026-06-01 lock ("all in-tree providers live in lattice-multibuffer/src/providers/, feature-gated") is superseded for providers owned by a subsystem: the owning crate owns the provider and takes a dep on lattice-multibuffer (acyclic — multibuffer depends on neither lattice-lsp nor lattice-magit). Rationale + the mode-ownership argument in multibuffer-views.md §3.7. search / narrow / problems stay put: no owning subsystem.

surround-mode landed (2026-07-25, branch feature/surround-mode): vim-surround semantics as a native minor mode in lattice-mode (SurroundMode, SU.1–SU.3b). Three grammar operators (surround-delete / surround-change / surround-add) registered in CommandRegistry at boot; keymap at MinorMode(surround-mode) with ChordPattern::CharLiteral wildcard capture for ds{char}, cs{char1}{char2}, yss{char}, and visual S{char}. Multi-char capture support added to action_from_bound_with_capture. 19 tests (8 unit + 11 operator dispatcher integration). Design: surround-mode.md; slice plan: surround-mode.md. Deferred to v2: ys{motion}{char} (needs post-motion char capture infrastructure), HTML/XML tag targets.

Recent Phase 5.8 polish slices (2026-05-22)

A two-day burst of user-driven bug fixes + small features brought the GPUI peer to functional parity with TUI for the window-management surface. Each slice is a single commit:

  • #16 (cb63f11) — vim chord clear-guard fixed: gg, zz, zt, zb, dw etc. broke when EnsureCursorVisible cleared partial_chord between keystrokes. Renderer-housekeeping actions now exempt from the clear-guard. Regression tests landed.
  • #17 (835a6d5) — GPUI viewport chrome math: pixel- accurate calc of pane padding + status row + global bottom padding so cursor doesn't park under modeline.
  • #18 (525f2f3) — LSP popup placement: GPUI honors PopupPlacement::CursorAnchored instead of always centring.
  • #19 / #21 (4d29ace) — State-B popup focus visual indicator (brighter border + hidden document cursor + header hint).
  • #20 (039881c) — hover popup auto-dismiss on cursor motion hoisted into Editor::dispatch (works in both peers).
  • #22 (ade38c6) — cmdline-completion <C-n> / <C-p> symmetric with picker.
  • #23 / messages overhaul (e5cf4d2) — tracing routed to BOTH stderr AND *messages* buffer via composed fmt + MessagesLayer; CLI -v / -q / --log-level flags via set_boot_log_level.
  • #24 (60c619e) — :describe-buffer no longer scrolls background document: GPUI uses pane.scroll / pane.cursor when popup owns active_buffer.
  • #25 (100c33f) — multi-pane viewport math + per-pane visible_spans so highlights work after splits.
  • #26 / popup-anchor (60c619e) — popup pinned to the symbol it was invoked from (CursorAnchored reads popup_substate.anchor, not live ad.cursor).
  • per-pane viewport (a446382) — PaneState.viewport_height
    • PaneState.viewport_width, set per-leaf by the renderer's per-frame layout pass; replaces the single global Editor::viewport_height. Active leaf's height is auto-mirrored back into Editor::viewport_height for the cursor-clamp / highlights-worker code paths.
  • #27 (e91298a, cb0111d) — critical mouse-focus regression: set_pane_viewport was firing N actor RPCs per frame, flooding the actor and blocking the GPUI main thread. Diff-then-send so steady state fires 0 commands.
  • #28 (3204029) — split ratios (<C-w>=, <C-w>+, <C-w>-, <C-w>>, <C-w><); PaneNode::*Split gains a ratio: f32 field; ratio-aware compute_rects_recursive
    • GPUI collect_pane_geometries.
  • #29 tabs slice 1 (57a3978) — TabId / TabSlot / Editor.tabs + Action variants (NextTab / PrevTab / GoToTab / NewTab / CloseTab) + chord bindings (gt, gT, {N}gt) + ex commands (:tabnew, :tabnext, :tabprev, :tabclose). Count plumbing via run_invocation intercept.
  • #29 tabs slice 2 (b19d228) — tabline rendering in both peers; new Tabline option group + tabline.show typed-enum (never / auto / always).
  • #29 tabs slice 3 (f138661) — :tabonly, :tabmove, :tabnew <path>, GPUI mouse-click on tab, <C-PageDown> / <C-PageUp> aliases.
  • #32 picker open-target (ea7891e) — picker <C-s> / <C-v> / <C-t> open file candidate in split / vsplit / tab; one-shot override via picker_open_target field with mem::take at do_picker_accept entry + restore-before-call pattern for all 7 inner apply_picker_outcome sites. Regression test: picker_open_target_resets_after_no_op_accept.
  • #33 picker legacy-path + GPUI symbolic-key adapter (ea1e546) — GPUI's keystroke.key uses names like "equal" / "plus" / "greater"; added symbol-name map so <C-w>= etc. resolve. Picker's legacy imperative arms (:b buffer picker uses these) now also honor the open-target override.
  • Font config (a224e52) — ui.font_family / ui.font_size options; GPUI cursor alignment fix.

Decoupled State-B detection (does the popup overlay have keyboard focus?) from content identity (is the buffer kind Help?). The old pattern active_buffer == BufferKind::Help served both roles; the new Editor::popup_focused bool (published in ActiveDocumentRenderState::popup_focused) is the dedicated focus-state signal.

  • New field (editor.rs, render_state.rs): pub popup_focused: bool on both Editor and ActiveDocumentRenderState, populated in build_render_state.
  • Mutation sites: focus_help_popup, open_popup_buffer(Steal), dismiss_popup (set/clear).
  • 20 State-B detection sites migrated across dispatch.rs, TUI (render.rs, runtime.rs), GPUI (window.rs): active_buffer == Helppopup_focused.
  • dispatch_fused: added pre_popup_focused param for the popup-dismissed ensure_cursor_visible skip.
  • active_text() / active_cursor() generalization: popup_buffer_content() replaces popup_help() for reading the popup buffer's text content — works for any document-shaped buffer (Help, Document, Messages, Multibuffer, Dashboard). active_cursor() returns self.cursor directly in State B (fixes a pre-existing staleness bug where the initial open-time stash was returned instead of the live cursor after motions).
  • 17 content-identity sites (pane_tree.active().buffer == Help) left untouched — they are genuine kind checks, not focus-state leaks.
  • Build: lattice-host, lattice-ui-tui, lattice-ui-gpui all clean. 697/698 tests pass (1 pre-existing copen alias failure).

Plugin-facing API surface preserved at four layers across all slices: Editor methods (do_*_tab, do_split_pane, etc.); typed Action variants; AppEffect variants; named CommandIds (action:next-tab, action:picker-accept-in-split, etc.). Plugin authors compose by any of the four.

Phase 7 (Plugin Host) is complete — paramount goal #2 (extensibility) now has its substrate: the WASM Component Model host runtime, the WIT API package, the capability/fuel/crash-isolation model, and every extension seam, each exercised end-to-end by a guest fixture. The natural next architectural step is Phase 8 (the plugin manager): editor-side loading, on-disk plugin discovery, the user init.rs → WASM config path, and shipping modes / reference plugins as components — i.e. wiring the finished runtime into the running editor (lattice-host does not yet depend on lattice-plugin-host).

LSP docs are comprehensive across audiences: design-doc readers (../architecture/design.md §5.4), implementers (../architecture/lsp-architecture.md), users (../../user/lsp.md + ../../user/lsp-mode.md), per-feature trackers (../notes/lsp-features.md -- every LSP 3.17 capability with status), slice plans (slice-plans/archive/lsp.md -- L1–L7 sequencing for the async-wake / lifecycle / status / diagnostics- presentation / nav-mode-ownership polish; complete — archived), and a manual verification checklist (verify.md §17–18 covering all 15 sub-modes + 4.4/4.5 features).

The modeline redesign (configurable element system — Left/Center/Right zones, per-span theme roles, event-bus content updates, mode/plugin- registered + interactive elements) is specified in ../architecture/modeline.md, with sequencing in slice-plans/archive/modeline.md (ML.0–ML.6; interaction ML.4 + plugin WIT ML.6 deferred).

Picker preview isolation (in-pane live preview renders the previewed buffer B as a read-only projection — baked into the published pane-tree leaves — never mutating the pane's committed buffer, the global active-buffer hot state, or the origin's resolved options / mode stack) is specified in ../architecture/preview-isolation.md, with sequencing in slice-plans/preview-isolation.md (PI.0–PI.5; complete 2026-07-07 — landed on both renderer peers, preview-mode read-only minor, cursorline preserved for the previewed buffer, per-buffer option resolution lifted to a renderer-agnostic RenderState::resolved_option_for seam, and the pre-PI swap/restore machinery deleted).

Phase 4 roadmap (history): 4.1 wire + actor + sync + diagnostics → 4.2 navigation + completion → 4.3 edits (rename / format / code action / will-save) → 4.4 polish (semantic tokens delta, inlay hints, folding, document highlight, selection range, pull diagnostics, dynamic registration, file watcher, configuration fan-out, progress, server-initiated UX) → 4.5 expansion (call / type hierarchy, code lens, document link, document color, moniker).


Option B: incremental reparse + span cache

The keystroke-to-fresh-tree architecture specified in ../architecture/design.md §5.3 / §8.2: every edit produces an EditDelta, threaded to the syntax worker which applies tree.edit() and runs Parser::parse(.., Some(&old_tree)) for incremental reparse. The frame-level span cache turns steady-state re-highlighting into a no-op.

Originally diagnosed against a user-visible bug ("after >> the highlighting breaks instantly; characters bleed into stale colours"). Root cause: spans computed against an old syntax snapshot were being painted onto current document bytes; the spans never caught up because (a) full reparse was the only path, and (b) document.text() allocated the full buffer on the input thread per keystroke. Both fixed.

SliceStatusWhat landed
B.1Buffer::apply_edit returns an AppliedEdit { ..., delta: EditDelta }. New EditDelta type in lattice-protocol::edit carrying tree-sitter-shaped byte/Position deltas. Six u32 writes + three Position copies at the tail of apply_edit -- bench input_edit_construction measures 1.87ns, at §8.2's ~2ns floor. Pure-data slice.
B.2/1Syntax::parse_at_with_edits(source, text_version, from_version, edits) applies each delta via tree.edit() then Parser::parse(_, Some(&old_tree)). Layered guards fall back to full reparse on any inconsistency (no cached tree, from_version mismatch, byte-length mismatch). SyntaxHandle worker coalesces queued requests by accumulating edits in arrival order, capped at 256 per burst.
B.2/2App accumulates EditDeltas on pending_syntax_edits via the four edit chokepoints (apply_edit_blocking, apply_edit_batch_blocking, undo_blocking, redo_blocking). maybe_reparse_syntax drains and ships them to the worker. Per-document last_synced_syntax_version on App + DocumentEntry. Includes the apply_edit_* family's &self → &mut self refactor (no interior mutability introduced).
B.3Frame-level highlight span cache on App, keyed (snapshot_ptr, text_version, scroll, viewport_height, fold_hash). Cache hit at 20ns flat across all corpus sizes -- 8900× speedup vs the pre-B.3 ~178µs full QueryCursor walk every frame. Steady-state norm (cursor blinking, no edit) → ~100% hit rate. Load-bearing for paramount goal #1's strict reading on the steady-state floor.
B.4Parametrized parity matrix in lattice-syntax::syntax::tests: 27 tests pinning incremental tree.to_sexp() == full-reparse tree.to_sexp() across edge positions, multi-line shape changes, whitespace-only edits, sequential batches, per-language coverage (Rust / Python / JavaScript / Markdown), pathological / minimal-buffer cases. Hardens the silent-wrong-tree surface.
B.5ReparseRequest carries Buffer (O(1) Arc-bump clone via ropey's internal sharing) instead of pre-materialized String. Worker calls buffer.as_string() on spawn_blocking thread. Bench clone_vs_text: 7.7ns flat across sizes vs. pre-B.5 path's 79ns / 990ns / 189µs (10 / 1k / 100k lines). 24,500× faster at 100k lines -- closes the last input-thread allocation in the syntax-reparse hot path per goal #1.

Combined post-Option-B per-keystroke cost on the input thread: ~100ns (Buffer::clone + Vec::take + mpsc send + counter bump). The actual reparse (tree.edit + Parser::parse) runs on the worker's spawn_blocking thread, ~50µs at 1600 lines / ~100µs at 16k lines.

Combined steady-state per-frame cost on the input thread: ~33µs (compose 13µs + cache check 20ns), down from ~192µs pre-Option-B (the ~178µs highlight_lines walk every frame was wasted work whenever nothing changed).

Bench rows live in benchmarks.md under "Native highlight" (B.2/B.4 incremental rows), "Frame render" (B.3 cache rows), and "Buffer ops" (B.1/B.5 rows). §8.2 floor-rows updated with measured numbers.

Two follow-ups deliberately deferred:

  • Per-pattern QueryCursor caching (benchmarks.md "Improvement target" on highlight::rust/200): drops the cache-miss highlight cost when an edit invalidates the span cache. Lower priority -- cache miss only fires on actual changes.
  • Per-pane edit accumulation for inactive panes (currently falls back to full reparse on the inactive-pane refresh path). Rare in practice; bounded.

C-series: runtime fixes + flicker elimination

A follow-up to Option B that turned the algorithmically-correct incremental-reparse pipeline into a user-visibly-flicker-free syntax-highlighting experience. Originally diagnosed against a specific user complaint: "even after Option B landed, syntax highlighting still goes through a brief 'gone, then back' phase on every edit -- especially noticeable on >> and dd. Vim has zero visual indication of any update. We should match that bar."

The story across five slices:

SliceStatusWhat landed
C.1#[tokio::main] on lattice-cli. Pre-C.1, main was synchronous and tokio::runtime::Handle::try_current() failed silently inside App::new, causing SyntaxHandle::seeded's worker to never spawn. Option B's entire incremental-reparse pipeline was routing through a non-existent worker -- spans stayed at the initial-seeded snapshot forever. Also adds nested-runtime-safe block_on (via block_in_place) so apply_edit_blocking etc. still work from inside the runtime.
C.2Worker publishes an intermediate snapshot AFTER tree.edit() shifts byte ranges, BEFORE running Parser::parse. The intermediate has the new source + tree-edited tree (byte ranges aligned with new content; tree shape pre-parse for the changed regions). Renderers see byte-aligned spans during the entire parse window -- lines below a delete or after a multi-byte insert paint at correct positions immediately. Splits parse_at_with_edits into try_apply_intermediate + reparse_with_cached_tree.
C.3Two complementary fixes: shift_highlights_for_edit runs synchronously in publish_document_changed to keep App.visible_highlights line-aligned across line-deletes / line-inserts (drains entries on delete; inserts empty placeholders on insert). refresh_highlights HOLDs the existing spans when snap.text_version() < document.text_version() instead of recomputing against stale data -- the cache only updates from one CORRECT set to another, never through an empty intermediate.
C.4Byte-shift spans within the affected line for in-line edits. >> (insert " " at line start) shifts every span on the line right by 4 bytes immediately. Crossing spans get their end extended/contracted; spans collapsed to empty get dropped. Held spans on frame N+1 are byte-aligned with new content, identical to what the worker's recompute will produce on frame N+2 -- zero visual transition.
C.5The missing link. Grammar-driven edits (>>, dd, cc, c, y, x, D, C, Y, every operator) flow through the dispatcher → actor → Effect::Edits(applied)App::handle_edits, which previously only updated the cursor. They bypassed publish_document_changed entirely, so all the C.1–C.4 machinery had no effect on the most common edit shape (operators). Routing them through the chokepoint fixed three things at once: spans byte-shift on input thread, pending_syntax_edits accumulates so the worker does incremental reparse instead of falling back to full, AND LSP didChange fires for operator edits (server-side document drift on >> / dd is gone).

User-visible result. The complete chain (B + C) gives:

  • No flicker on >> — the indented line's spans byte-shift immediately on the input thread; the worker's recompute lands with identical spans; the renderer never paints a transition.
  • No flicker on dd — the deleted line's entry drains; lines below inherit their (still-correct) spans at their new indices; the worker's recompute confirms.
  • No flicker on typing — single-char insert/delete byte-shifts spans on the affected line; the user sees colors track the cursor's edit point continuously.
  • Steady-state highlight cost stays at ~20ns/frame (the B.3 cache hit). The C-series is correctness-not-perf for the hot path; it adds a sub-µs shift_highlights_for_edit call per edit but no per-frame overhead.

Implementation surface.

  • lattice-cli/src/main.rs: #[tokio::main(flavor = "multi_thread")].
  • lattice-runtime/src/runtime.rs::block_on: block_in_place wrap when nested in a runtime context.
  • lattice-syntax/src/handle.rs::worker_main: two-stage parse with intermediate ArcSwap::store between try_apply_intermediate and reparse_with_cached_tree. New seeded_with_runtime constructor (kept alongside seeded for tests in tokio context).
  • lattice-syntax/src/syntax.rs: split parse_at_with_edits into try_apply_intermediate (fast, no parse) + reparse_with_cached_tree (slow, parse). The convenience method runs both back-to-back.
  • lattice-ui-tui/src/app/highlights.rs:
    • App.visible_highlights_key: cache key holds syntax_text_version only (not document_text_version) so edits don't trigger recomputation against stale snapshots.
    • refresh_highlights stale-snapshot HOLD path.
    • shift_highlights_for_edit — line-shift on edit.
    • shift_spans_within_line — byte-shift on in-line edit.
  • lattice-ui-tui/src/app/dispatch.rs:
    • handle_edits — routes grammar-driven edits through publish_document_changed (the latter lives in app/lifecycle.rs).

Three follow-ups deliberately deferred (open):

  • LSP-side staleness symptoms: diagnostics retention (:diagnostics shows errors that no longer exist), hover-position offset on rapid edits. C.5's didChange-now-fires-for-operators may have partially fixed hover staleness as a side effect; diagnostics retention is a separate decoration-pipeline bug.
  • Per-pattern QueryCursor caching: the benchmarks.md improvement target on highlight::rust/200 (3.2ms → ~1ms achievable). Cache miss only fires on actual changes; lower priority.
  • Per-DocumentEntry edit accumulation for inactive panes: currently they fall back to full reparse on the rare cross-doc refresh path. Bounded scope.

B': mode-owned synthetic buffers

The "modes own their buffers" contract specified in ../architecture/design.md §5.10.5: every synthetic buffer (*lsp*, per-instance *lsp:<server>:<workspace>*, per-instance *...:trace*, *messages*, future *scratch* / *compilation* / REPL / plugin-emitted streams) is owned by a dedicated mode end-to-end. The App holds no subsystem-specific buffer-handling code; two host primitives -- BufferStore + ServiceRegistry -- carry the contract. Anchors: design.md §5.10.5 + §5.4.7 + mode-architecture.md §7.

Originally motivated by user-visible bugs (modeline [no name] on :lsp-log, <C-o> "no jumps" from synthetic buffers, missing per-instance separation for multiple rust-analyzer processes against different workspaces) plus the architectural observation that App::ensure_lsp_subsystem_buffer / App::drain_lsp_log_events / App::append_to_owned_buffer were anti-extensible: every new subsystem would copy-paste into the App. The recipe needs to be mode-shaped instead.

SliceStatusWhat landed / what lands
B'.1aBufferStore trait + BufferStoreHandle defined in lattice-mode. Send + Sync so a mode's tokio task can call from any thread. Methods: find_by_name, handle_for(id), insert_document_buffer. (2026-07-22: the original ensure_named_document(name, major, flags) was removed — its &self impl could only find [activating a mode needs &mut Editor], so it panicked on a miss. Creation moved to the &mut-backed ModeActivator::ensure_named_document; see design.md §5.10.5.)
B'.1bBufferRegistry switched to interior mutability (Arc<Mutex<BufferRegistryInner>>). Methods take &self and lock internally; the type is Clone (Arc bump) so it threads naturally into BufferStoreHandle. Callback-based read/write API (with_entry, with_help, with_oil, with_file_tree, for_each) replaces reference returns -- guards never escape the lock. 30+ App-side call sites migrated; 1574 lattice-ui-tui tests stay green. Sharp edges to honour: no lock-across-.await, no re-entrant with_X(|_| with_Y(...)) (std::sync::Mutex is not reentrant).
B'.2Per-instance LSP logger keying. InstanceKey { server_id: Arc<str>, workspace: Arc<Path> } is the new canonical key; LspLogger's per-server map becomes per-instance. LogRecord and LspLogPushed carry workspace alongside server_id. All four Inbound* bus payloads (applyEdit / configuration / showDocument / showMessageRequest) carry workspace so the App-side drain can route logs and side-effects to the correct instance. ServerHandle::instance() / ::workspace_root() expose the canonical pair. App ex-commands (:lsp-log-level / :lsp-log-clear / :lsp-trace) walk running actors + the logger's known_instances, with a cwd-synth fallback so pre-spawn toggling still works. 1574 lattice-ui-tui tests pass.
B'.3LspLogMode owns *lsp*. Hand-written Mode impl: on_activate pulls the buffer's DocumentHandle via the BufferStoreHandle service, subscribes to LspLogPushed events, spawns a tokio task that drains the subscription, coalesces, and applies one batched edit per drain cycle. on_deactivate removes the buffer-local subscription stash + unsubscribes. The App's drain_lsp_log_events skips the subsystem write when LspLogMode is the active major on *lsp*. BufferStore impl for BufferRegistry; BufferStoreHandle registered in ServiceRegistry at boot. format_log_event_line hoisted to lattice-lsp::logging so the mode can reuse it. Mode degrades gracefully when no service or runtime is wired (test paths).
B'.4LspServerLogMode owns per-instance *lsp:<server>:<workspace>*. Hand-written Mode impl reads its InstanceKey from the LspServerLogInstance buffer-local (seeded by App before activation), subscribes to LspLogPushed, filters by (server_id, workspace), skips trace records, batches appends. Buffer name + ensure helpers take &InstanceKey; App drain accumulates by (server_id, workspace) and skips per-server write when the mode is the active major. Both B'.3 and B'.4 modes also seed their buffer from the in-memory ring on activate so pre-existing records show up immediately; LspLogger registered as a service for the seed path. open_lsp_log_in_pane / open_lsp_trace_log_in_pane use a resolve_lsp_instance_for helper (running actor → known-by-logger → cwd-synth fallback).
B'.5LspTraceLogMode owns per-instance *lsp:<server>:<workspace>:trace*. Hand-written mirror of B'.4 with inverted filter (level == "trace" || source == "trace"). Reuses the LspServerLogInstance buffer-local for instance identity; tracks its own subscription via LspTraceLogSubscription so a single instance can have both buffers open simultaneously. Seeds from the in-memory ring on activate. App drain skips per-trace write when the mode is the active major. Retired the lsp_log_mode! macro since all three log majors are now hand-written.
B'.6App LSP cleanup. App::drain_lsp_log_events slimmed to only fan window/showMessage-sourced records to the minibuffer -- every buffer append is mode-owned now (B'.3 / B'.4 / B'.5). format_log_event_line alias retired from lattice-ui-tui (canonical is in lattice-lsp). App::ensure_lsp_* buffer creators and append_to_owned_buffer stay -- still used at boot (subsystem buffer) and by ex-commands (per-instance lazy create); the modes activate on top. Added log_append_pipeline_100_records Criterion bench in lattice-lsp/benches/lsp.rs: publish 100 records through LspLogger → EventBus → mpsc → coalescing drain → format, measure wall time. Baseline ~87µs / 100 records (≈870ns / record) on dev hardware. Excludes the apply_edit_batch tail (depends on lattice-grammar, out of scope for this crate's deps); the document-actor benches already measure that side.
B'.7:lsp-log / :lsp-server-log / :lsp-trace-log reshaped as thin wrappers. Canonical name builders + inverse parsers moved into lattice-lsp::buffer_names (LSP_SUBSYSTEM_LOG_NAME, lsp_server_log_name(&InstanceKey), lsp_server_trace_log_name(&InstanceKey), parse_lsp_server_log_name(&str) -> Option<InstanceKey>, parse_lsp_trace_log_name). BufferStore::name_for(id) -> Option<String> added so modes can read their buffer's synthetic name. LspServerLogMode::on_activate / LspTraceLogMode::on_activate now derive their InstanceKey straight from the name — the LspServerLogInstance buffer-local + the App-side seeding before activation are gone. App side collapsed ensure_lsp_subsystem_log_buffer / ensure_lsp_server_log_buffer / ensure_lsp_server_trace_buffer / ensure_lsp_log_buffer_with_instance / ensure_lsp_log_owned_buffer into a single generic ensure_named_synthetic_document(name, mode_id, flags) -> BufferId host helper. Ex-command handlers (do_open_lsp_log, open_lsp_log_in_pane, open_lsp_trace_log_in_pane) + the :lsp-trace toggle path + the boot path + the *messages* creator all route through that one helper; the handler computes name via lattice-lsp and picks the major-mode id, and that's all the subsystem-shaped knowledge it has. Drains of drain_lsp_log_events removed from the open paths (modes drive buffer content from the event bus, independent of that minibuffer-only drain). 1574 lattice-ui-tui tests + 183 lattice-lsp tests stay green.

Design-call answers baked in (per architect's confirmations):

  • Buffer name canonicalization — full workspace path as the canonical registry key; display label may shorten with ~/.
  • Server-detach lifecycle — the mode's on_deactivate runs when the supervisor signals server-exit; the subscription drops; the buffer survives (frozen — no further appends). :bd is the explicit removal path.
  • Memory cap — unbounded transcript by default; :lsp-log clear drops the rope. Matches user mental model ("the buffer is the full log").
  • Subscription strategy — one publisher (LspLogPushed), three filtering subscribers (one per mode). Simpler than three sub-publishers; tokio broadcast is the fan-out channel.
  • Bench coverage — single cross-task append-throughput bench in B'.6 alongside the App-side delete, so regressions show up in the same slice that removes the old fallback path.

M-async — async mode lifecycle (v1 commitment)

Async mode lifecycle specified in mode-architecture.md §7.1. Mode::on_activate returns a LifecycleFuture<'_, Self::Guard> that the dispatcher schedules on the runtime; deactivation drops the typed Guard, firing its Drop impl for synchronous cleanup (async cleanup uses spawn_task fire-and-forget — Zed's pattern). The UI thread never blocks on mode activation. Hooks that need to spawn a supervisor, await a handshake, drain a watcher, or close a server connection do so naturally with .await.

Why v1, not post-v1. Paramount goal #1 (sub-frame input latency) and paramount goal #4 (asynchronicity) both demand that no synchronous path on the App thread does I/O. Today's LspMode::on_activate is the empirical counter-example: first cargo run blocks measurably while the rust-analyzer supervisor spawns + initialises. Subsequent runs don't block because the work is cached. The architectural fix is async lifecycle with typed Guards, not caching.

Sequence: M-async lands after B' (committed at 7aca41d) and before messages-mode v1. B' establishes buffer-ownership with sync activation (all activation sites are ex-command handlers — sync is correct for those). M-async generalises the lifecycle so messages-mode v1 gets async + Drop-based cleanup for free.

Design ref: docs/dev/architecture/mode-architecture.md §7.1 (Drop-based Guard rationale, ctx shape, activation/deactivation flow, re-entrancy invariants). Settled 2026-05-14; do not re-litigate.

SliceStatusWhat lands
M-async.1Drop-based Mode trait redesign (sync drive). Workspace-green. Mode trait gained type Guard: Send + 'static; on_activate returns LifecycleFuture<'_, Self::Guard>; on_deactivate removed (Drop = cleanup contract). DynMode (pub adapter trait) + blanket impl<M: Mode> DynMode for M does Box<dyn Any + Send> erasure. ModeContext redesigned: Send + 'static, no BufferLocals field, owned Arc handles (config, events, services). GuardStore (HashMap<(BufferId, ModeId), Box<dyn Any + Send>>) passed &mut to registry methods. Registry drives lifecycle futures synchronously via poll_now; deactivation drops the Guard. All Mode impls migrated atomically — marker modes (lattice-mode / -syntax / -snippet / -file-tree / -oil) get type Guard = ();; the five hand-written LSP modes (LspServerLogMode, LspLogMode, LspTraceLogMode, LspMode, LspFoldingMode) get typed Drop-based Guards; lsp_sub_mode! macro emits markers. App: services: ServiceRegistry → Arc<ServiceRegistry>, new mode_guards: GuardStore field, dispatcher caller sites updated, registry tests rewritten with MockMode/MockGuard drop-counter, TestLocalsMode Guard test in dispatch.rs. Workspace green: 1573 lattice-ui-tui + 191 + 17 + 9 + 12 + 4 lattice-lsp + 113 lattice-mode + remainder. End state: same user-visible behavior as before (App still blocks during lifecycle); new API shape in place for M-async.2 and M-async.3.
M-async.2Spawn-based lifecycle dispatch. Workspace-green. Registry's lifecycle drive swapped: poll_now(...)lattice_runtime::spawn_task(async move { ... }). New spawn_task helper added to lattice-runtime. Sync prefix synchronously mutates active_modes; spawned task awaits on_activate_dyn(ctx), stashes the Guard, publishes MajorEntered / MinorActivated on success or ModeActivationFailed on Err (variant added; carries stringified reason). ModeEvent registered as TypedEvent (mode.lifecycle) via register_event! so the bus carries it. Registry method signatures: Result<Vec<ModeEvent>, _>Result<(), _> (validation errors only). GuardStore wrapped in Arc<Mutex<...>> newtype GuardStoreHandle so the spawned task (tokio worker) and the App thread can both lock briefly without &mut lifetime gymnastics. linkme added as direct dep on lattice-mode (proc-macro path requirement). App caller sites updated: services already Arc-wrapped from M-async.1, mode_guards: GuardStore → GuardStoreHandle, deactivate methods now take &Arc<EventBus> so they can publish the deactivation event. Registry tests are #[tokio::test], subscribe to ModeEvent on the bus, .await the channel for spawned-task completion. Workspace green: 113 lattice-mode + 191 lattice-lsp + 1573 lattice-ui-tui + remainder pass. User-visible win: the App thread no longer blocks on on_activate.await. Today's modes happen to be immediately-ready (they tokio::spawn work and return), so the win is theoretical for now -- it lands fully when LspMode::on_activate is rewritten to .await the LSP initialize handshake (a separate refinement).
M-async.3Cascade ordering + rollback. Workspace-green. Cascade refactor: sync prefix walks the requested mode + its implies() tree, validates each, mutates active_modes for the whole tree, builds an ordered Vec<CascadeStep>. One driver walks the plan DFS, awaiting each step's on_activate.await before the next — eliminates races where a sub-mode reads parent's not-yet-written state. The driver is try-sync-then-spawn: polled once with a no-op waker; if the cascade is immediately-ready (today's marker modes + sync LSP modes), it completes on the App thread with no spawn boundary. The first Pending yield trips the spawn path; the remainder runs on the runtime. On lifecycle error the driver publishes ModeActivationFailed for the failing step plus a synthetic ModeActivationFailed { reason: "cascade aborted by X" } for every remaining unrun step. App-side drain_mode_lifecycle_events subscriber drains ModeEvent from the bus per tick; for each ModeActivationFailed it calls deactivate_mode_by_id. New test cascade_abort_publishes_synthetic_failures_for_unrun_steps documents the abort path; implies_auto_activates_dependency updated to assert sequential DFS order (parent first).
M-async.4Per-pair epoch counter for activate / deactivate serialization. Workspace-green. Eliminates the latent race that would surface when a mode's on_activate future genuinely .awaits (yields Pending on first poll): a synchronous deactivate arriving while the spawn is in flight can no longer leave a leaked Guard in a logically-inactive store slot. GuardStore grew epochs: HashMap<(BufferId, ModeId), u64>. New API: bump_epoch(buf, mode) -> u64, current_epoch(buf, mode), try_insert(buf, mode, my_epoch, guard) -> Result<(), Box<dyn Any + Send>>. remove(buf, mode) bumps the epoch internally before removing, so any in-flight spawn's later try_insert fails the match. purge_buffer(buf) also bumps all (buf, *) epochs. CascadeStep carries the epoch: u64 captured when the sync prefix queued it; the spawn driver calls try_insert with that epoch on on_activate.await success. On stale (Err(stale_guard)), the Box is dropped on the spawn side — the original Guard's Drop fires for out-of-band cleanup (publishes LspBufferDetached, restores foldmethod, etc.). No MajorEntered / MinorActivated published for the stale activation. New test rapid_deactivate_during_pending_activate_drops_guard_on_spawn_side exercises the race with a synthetic .awaiting mock mode gated on a tokio::sync::oneshot. Doc update in mode-architecture.md §7.3.1. Mechanism: lock-free epoch counter, not per-pair Mutex. Considered + rejected: tokio::sync::Mutex per pair (deactivate would block / spawn extra task); epoch counter is minimal, sync-deactivate-friendly, and matches the Drop-based cleanup contract.
M-async.5LspMode::on_activate awaits initialize handshake. Workspace-green. Rewrote LspMode::on_activate to drive the supervisor's open_buffer(path, text).await directly. The mode's "active" state genuinely tracks LSP readiness; the previous split (LspMode publishes intent → attach_driver does the work async) is gone. Modes own their work; the App is no longer a coordinator between Event::DocumentOpened and the LSP attach path. Flow: LspMode::on_activate resolves path + text via ctx.service::<BufferStoreHandle>(), calls supervisor.open_buffer(path, text).await, then publishes LspBufferAttached after the await completes (subscribers now see it as "operational"). On error → Err(LifecycleFailed)ModeActivationFailed → App rollback. Path-less (scratch) buffers and missing-supervisor (test) paths short-circuit gracefully (no .await, no spawn). Removed: lattice-lsp/src/attach_driver.rs (52 LOC) + its boot-time attach_driver::spawn call. M-async.4 epoch counter is the load-bearing primitive: rapid :lsp-mode toggle while initialize is in-flight is reconciled lock-free (try_insert mismatch → Guard drops on spawn side → LspBufferDetached publishes). Bench crates/lattice-lsp/benches/mode_activate.rs: dispatch latency for 16-step lsp-mode cascade (with sub-modes) under two cases — no-server-config (open_buffer round-trips supervisor mailbox, returns Ok(empty)) and unregistered-supervisor (mode short-circuits). Measured 6.2 µs per activate/deactivate cycle in optimized builds (both cases); pinned in CI to catch regressions. Three App-level tests updated to #[tokio::test(flavor = "multi_thread")] with poll-and-sleep waits (the activate is now genuinely async so deactivate races the spawn — wait pattern matches the M-async.4 race test). Workspace green: 1573 lattice-ui-tui + 180 lattice-lsp + 76 lattice-mode + remainder pass.

messages-mode v1 (tracing-bridged echo log)

The *messages* buffer specified in ../architecture/design.md §5.10.6: every record App::set_message produces, plus every tracing::* event from the editor + plugins, flows through a single subscriber into one mode-owned buffer. Anchor: design.md §5.10.6 + §5.10.5 (the B' contract it builds on).

May land in parallel with B' (no dependency); the async pieces (tracing subscriber's append task) benefit from M-async-activate once that lands but can ship with sync activation if scheduling dictates.

SliceStatusWhat lands
msg-mode.1Tracing bridge + MessagesMode (combined .1 + .2 of original spec). Workspace-green. lattice_grammar::EchoLevel gains Trace + Debug variants + From<EchoLevel> for tracing::Level + inverse impls. New MessagesLayer (tracing_subscriber::Layer) in lattice-runtime captures every event into a shared Arc<Mutex<MessagesRing>> + publishes MessagePushed on the editor event bus. New install_messages_subscriber(ring, bus) (idempotent via OnceLock) called once at App boot. New MessagesMode (Major, ReadOnly = true) in lattice-mode/src/modes/messages.rs replaces the text-mode + read-only-mode combo on *messages* — symmetric with lsp-log-mode for *lsp*. App.messages now Arc<Mutex<MessagesRing>> so the boot-installed layer (running on whatever thread emitted the event) can push into the same ring the App reads on the main thread for backlog seeding. Two paths, one destination: App::set_message keeps its direct ring + bus push (preserves per-App isolation for tests); the MessagesLayer captures all other tracing::* events in the editor (LSP layer logs, plugin host once 1.0 lands) into the same ring + bus. Both produce identical MessageRecord shapes. Deferred from spec: mode-driven subscriber lifecycle (Drop the subscriber when :messages-mode toggles off) — set_global_default is process-wide; making it mode-owned needs reload-handle plumbing (v1.1).
msg-mode.2messages.filter typed option. Workspace-green. New Messages option group + MessagesFilter: String (default "info"). lattice_runtime::install_messages_subscriber now takes an initial filter spec + wraps the layer in tracing_subscriber::reload::Layer<EnvFilter>; the reload handle is stashed in a process-global OnceLock. :set messages.filter=editor=info,lsp=debug,grammar=trace triggers the option-change cascade which calls reload_messages_filter(spec) to swap the directive live without restarting the editor. Validator (validate_messages_filter) calls EnvFilter::try_new at :set time so unparseable directives are rejected before they reach the runtime. tracing-subscriber workspace dep gained env-filter feature. MessagesFilterReloadError enum carries the parse / no-subscriber / reload-failed cases for diagnostics (App surfaces as a warn echo if the reload fails on a test path).
msg-mode.3Syntax highlighting for *messages*. Workspace-green. Theme gains messages_timestamp_style (dim), messages_trace_style (dim), _debug_style (cyan), _info_style (default), _warn_style (yellow + bold), _error_style (red + bold). New messages_line_spans(line, theme, max_width) in render.rs scans the fixed HH:MM:SS.mmm LEVEL text format produced by format_message_record (byte offsets 0..12 timestamp, 13..18 level token, 19.. body) and emits a ratatui Vec<Span<'static>> with per-token styles. In compose_visible_lines_inner, when the active pane's major mode is messages-mode (checked via `app.active_modes.get(&id).and_then(
msg-mode.5Plugin host bridge (post-1.0 WIT): host.log(level, target, msg) flows through the same subscriber. Plugin telemetry shows up in *messages* with no plumbing on the plugin side. Gated by Phase 7.

Phase 5 — GPU rendering foundation (host extraction → GPUI peer)

Anchor: ../archive/phase-5-extraction.md (the audit + slice plan). Anchor: ../architecture/design.md §5.6 (renderer trait + layered architecture), §11 (project layout), §13 (Phase 5 / Phase 6 roadmap).

lattice-ui-tui is currently the host -- it owns App, dispatch, every picker source generator, LSP coordination, mode lifecycle, options cascade, AND ratatui paint. Phase 5 extracts the renderer-agnostic substrate into a new lattice-host crate so GPUI can be added as a peer (under lattice-ui-gpui), with lattice-ui-tui shrinking to a ratatui adapter behind the lattice-render::Renderer trait. The TUI moves behind lattice --tui; GPUI becomes the default only after parity.

Decision-making heuristics applied (../architecture/CLAUDE.md): Best long-term fit beats easy implementation -- extracting the host is months of work; the alternative (drop GPUI on top of the current entangled lattice-ui-tui) would compound the coupling that already needs unwinding. Confirm the plan before non-trivial work -- the 5.0 audit doc IS that confirmation step.


Phase 5.8.AF.5 — UI-thread relocation (paramount goal #4)

Anchor: ../architecture/design.md §5.7 (the async runtime + threading section, expanded with subsections §5.7.1 through §5.7.6 describing the architectural shape).

After 5.8.AF batch-3 closed the App-only Effect handler chapter, an audit of Editor::run_tick_pending revealed 47 separate per-tick operations running on the renderer thread -- LSP cache drains, request-firing pumps, file-watcher install + event fan-out. Most are non-blocking by virtue of try_recv, but two of them (refresh_lsp_file_watcher + drain_lsp_fs_events) blocked the UI for minutes on :e <rust-file> due to notify::Watcher::watch(workspace_root, Recursive) walking populated target/ directories synchronously.

This phase relocates every UI-thread drain onto background tokio tasks via two primitives: RenderState (the renderer's wait-free read contract) and PerBufferCache<T> (copy-on-write shared HashMap). The migration shape is mechanical -- each drain follows the same template -- so once the substrate lands, the remaining sub-slices land per-feature without re-litigation.

SliceStatusWhat lands
5.0Extraction audit doc (docs/dev/archive/phase-5-extraction.md). Every module under crates/lattice-ui-tui/src/ classified (HOST / TUI_RENDER / TUI_INPUT / THEME_TUI / BOOT / MIXED). Hard cases enumerated: theme is ratatui-typed throughout and leaks into App.theme; chord.rs + input.rs mix the renderer-neutral chord representation with crossterm adapters; pane_render.rs typedef hardcodes &mut ratatui::Frame, Rect. Target crate layout named. Slice ordering 5.1 → 5.last fixed.
5.1lattice-host shell created. New crates/lattice-host with empty lib.rs (docs only, no exports yet). Added to workspace members. lattice-ui-tui declares it as a dep (unused so far -- the seam is in place; 5.2 starts using it). Builds + 1592 ui-tui tests green; 0 lattice-host tests (intentional, the crate is intentionally empty).
5.2✅ doneMove pure HOST modules. Everything classified HOST in the audit that doesn't transitively touch theme.rs or chord.rs. lattice-ui-tui re-exports the moved types via pub use lattice_host::*; so downstream consumers don't break. Waves shipped: trivial shims; actions + excommand; host_generators; keymap leaves (keymap.rs, keymap_trie.rs, keymap_registry.rs). Action extracted (post-keymap-leaves): the Action enum + FindKind + EchoMessage + EchoLevel move to lattice_host::action. lattice-ui-tui::app re-exports them. Keymap catalogs closed (5.4 unblock): the four keymap_normal/insert/visual/replace.rs files (~4,600 LoC of production code) and input.rs all move to lattice-host after the dispatch refactor lands in slice 5.4 — dispatch_* and translate* take KeyChord directly, the crossterm-coupled KeyEvent → KeyChord conversion lives only at the renderer boundary. See row 5.4 for details.
5.3Renderer-neutral theme. lattice_host::ui::theme::{Theme, Style, Color, NamedColor, Modifiers} defines the canonical neutral types; Color carries Default/Named/Indexed/Rgb variants. lattice_ui_tui::theme::Theme now grows a From<&host::Theme> impl + host_style_to_ratatui + host_color_to_ratatui helper conversions; parse_color delegates to the host parser. App gains host_theme: lattice_host::ui::theme::Theme alongside the existing TUI cache theme: TuiTheme; sync_theme_from_config writes the host theme first, then rebuilds the cached TUI adapter via Theme::from(&self.host_theme). Render call sites unchanged (still read app.theme.foo_style). Transitional duplication -- both fields live on App; when GPUI lands and the TUI cache moves off App, the cached theme field collapses out and host_theme becomes the single canonical source. Tests: host_theme_default_adapts_to_tui_theme_default (round-trip default match), parse_color_routes_through_host, ui_set_cascade_keeps_host_theme_and_tui_theme_in_sync (cascade contract). 1535 ui-tui + 64 lattice-host = 1599 total.
5.4✅ doneSplit chord.rs + input.rs + keymap family. Renderer-neutral KeyChord + the unified translate(ctx, chord) -> Action dispatcher + every per-mode catalog now live in lattice-host. The crossterm KeyEvent → KeyChord adapter stays in lattice-ui-tui::chord as free functions (orphan rules); lattice-ui-tui::input is a ~30-line crossterm shim that converts the event and forwards to lattice_host::input::translate. Shipped in five slices (commits 3087e5d, 08b45a3, b2e555a, b70baa0, fcb5512): (1) leaf translators translate_search / translate_command / translate_command_chord_capture / translate_picker take KeyChord; (2) translate_normal + compute_normal_action + lookup_normal* take KeyChord; the chord conversion hoists to a single point at the top of translate(); (3) keymap_normal.rs (2,771 → ~1,565 LoC of production code) moves to host; (4) keymap_insert / keymap_visual / keymap_replace move to host with their dispatch_* taking &KeyChord; (5) input.rs itself moves to host and lattice-ui-tui::input shrinks to the crossterm shim. Test modules stay in lattice-ui-tui next to the renderer-coupled ev() helpers that build chords via chord::from_event(&KeyEvent { ... }). 1424 ui-tui + 177 host + 180 lsp green.
5.BApp → Editor composition migration. See ../archive/phase-5b-app-design.md. 5.B.0 audit (f7416a1), 5.B.1 host Renderer trait + MinimalRenderer (6f651a4), 5.B.2 App<R> generics (3649c18, reverted), 5.B.3 Option-E pivot (3e0db86) — Editor defined, App.editor field added. Clusters 1–12 from the plan landed across 5.B.4–5.B.19: macros (8fc1d5b), marks+registers (58fb496), position history + tag stack (cedbaca), search (7a9fb4d), vim repeat + visual (1944035), replace+insert (0879473), popup 3/4 (4c7bd1e), cmdline+echo (49bbe15), syntax (e9203e1), picker (f3d0652), config+modes (2eb61ad), modal+dispatch (37b4dbd), active-pane subset (94dcebb), LSP per-buffer caches (acf5b2a), LSP scaffolding (d00b4d9), LSP subsystem + server channels (f1ba0f9), LSP request channels (d5b5b82). C-wave finished the keystone fields: C1 cmdline CompletionState + completion_registry (0d84c6f), C2/C3 VisibleHighlightsKey + PopupSnapshot to host (bb4911e + e51db26), C4a pane_tree (ed8c4ec), C4b/c document + snapshot_cache (db31aeb), C4 final folds (c57346c), C5 LspFileWatcher (e51b4ed + 2e3af79 + be5c1d0 + 3465f6f + 9401d4e). 5.B.20 (f99af23) closed out the completion-cluster tail: insert_completion, snippet_registry, insert_completion_snippet_meta, completion_accept_freq, per_language_completion, completion_in_path_context, active_snippet, snippet_dirs, popup_back_stack, pending_config_structural_sections; SnippetCandidateMeta moved to lattice-host::state. Endpoint: App holds {editor, pane_render_registry, theme, lsp_file_watcher} only — 4,040 LoC; Editor is 822 LoC. Tests: 1424 ui-tui + 177 host + 180 lsp = 1781, identical to pre-5.B baseline.
5.5🟡 in progressDispatch extraction (App::applyEditor::dispatch). Largest remaining architectural unlock. Move the ~2.6k-LoC apply body + apply_effect table + do_* helpers from lattice-ui-tui::app::dispatch to lattice_host::editor::Editor::dispatch(action) -> DispatchOutcome. Adds a small RendererSignal enum (likely just ThemeChanged + Quit at v1) for the host-to-renderer side-effects today's TUI handles implicitly via per-tick repaint. Sliced 5.5.A–H. After this, lattice-ui-gpui can call editor.dispatch(action) with zero lattice-ui-tui dependency. Focused design doc: ../archive/phase-5-dispatch-extraction.md. ~1-2 weeks. Sub-slice log: 5.5.A scaffold Editor::dispatch + DispatchOutcome + RendererSignal (ff6add5) ✅; 5.5.B preamble — macro-recording capture + partial-chord clear → host (fdbbf54) ✅; 5.5.C move 10 helper-free Action arms into Editor::dispatch (6c7ee59) ✅; 5.5.D pure-editor helpers + read-only-help guard → host (a743178) ✅; 5.5.E.1 scaffold Editor::handle_effect + migrate the three helper-free Effect arms (Effect::None, Effect::ClearSearchHighlight, Effect::Echo) + relocate echo_level_from_grammar (b319e29) ✅; 5.5.E.2 echo-only ex-command listers (Effect::EchoMarksEditor::do_list_marks, Effect::EchoRegistersEditor::do_list_registers) + co-move preview_register (now pub in lattice_host::dispatch; ui-tui picker call sites updated) (0198827) ✅; 5.5.E.3 store_yankEditor::store_yank (Effect::Yank migrated; oil-buffer narrow apply_effect re-implementation reroutes through self.editor.store_yank) (8de114b) ✅; 5.5.E.4 set_selections_blocking + publish_selections_changedEditor; visual_kind_to_mode → host-side pub fn (Effect::SelectionChange migrated; all 13 ui-tui call sites — production + tests across app/visual, app/lsp, app/edit, app/dispatch, render — reroute through editor.set_selections_blocking / lattice_host::dispatch::visual_kind_to_mode) (2e33e46) ✅; 5.5.E.5 architecture-first: thread DispatchOutcome through App::apply_effect (rename _outcomeoutcome, add handle_renderer_signal drain loop matching ThemeChangedrebuild_tui_theme + Quit no-op) + split sync_theme_from_config into the existing host-theme writer + a new App::rebuild_tui_theme wrapper for the renderer-specific Theme::from tail. Signal pipe wired end-to-end; the first real emission lands in E.6 when do_set migrates host-side and Effect::SetOption pushes ThemeChanged on ui. cascade (42c4848) ✅. 5.5.E.6 option-cascade infrastructure → host: tui_options.rs (typed-option declarations: ui.dim_inactive / ui.separator{,_color} / ui.statusline_{active,inactive}_fg / ui.nerd_fonts) relocated to lattice_host::ui::theme_options (renderer-neutral; linkme self-registers regardless of source crate, boot init_from_linkme() picks them up unchanged); new Editor::sync_host_theme_from_config reads the typed options + writes editor.host_theme; App's sync_theme_from_config collapses to editor.sync_host_theme_from_config(); self.rebuild_tui_theme(). Pure-editor helpers (rebuild_option_cache, recompute_options_for_buffer, resolved_option<D>) moved to Editor; App-side methods become 1-line delegates so the ~37 hot-path call sites for app.resolved_option::<X>(buf) etc. compile unchanged. Cascade engine (do_set, drain_option_changes, apply_option_cascade) migrated to Editor; cascade emits new RendererSignal variants (NerdFontsToggled, MirrorOptionToModes(String), LspConfigChanged(String)) for renderer-coupled tails (set_file_tree_nerd_fonts rope refresh, mode-mirror activate/deactivate, LSP didChangeConfiguration fan-out) — RendererSignal dropped Copy to admit owned-String payloads; messages.filter reload + relativenumber-implies-number cascade + foldmethod recompute run host-side directly. Effect::SetOption arm migrated to Editor::handle_effect — App's apply_effect collapses it into the grouped no-op. lsp_server_scope helper + its unit test relocated alongside. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.1 display-buffer signal pipe: introduces RendererSignal::DisplayBuffer(Box<DisplayBufferRequest>) carrying { content: lattice_help::HelpContent, category: BufferDisplayCategory }; drops PartialEq / Eq derives on RendererSignal (HelpContent's syntax-highlight cache isn't value-equatable). Migrates Effect::ListBuffers and Effect::DescribeBuffer: host-side content builders Editor::build_list_buffers_content and Editor::build_describe_buffer_content build the HelpContent from editor.* reads (buffer-registry walk + per-kind formatting using lattice_file_tree::modes::FileTreeRoot / lattice_oil::modes::OilDir from buffer_locals; modes / cursor / dirty / mark / fold counts + lattice_help::mode_link link rendering). The Effect arms in Editor::handle_effect push RendererSignal::DisplayBuffer(...); App's apply_effect collapses both into the grouped no-op; App's handle_renderer_signal::DisplayBuffer(req) routes through the existing App::display_buffer (which still owns popup / pane / split surface dispatch). The App-side do_list_buffers (~110 LoC) and do_describe_buffer (~62 LoC) bodies delete entirely (no remaining callers); the stale mode_link import in app::help drops. The App's do_set wrapper picks up #[allow(dead_code)] since prod no longer routes through it (E.6 already moved the cmdline path; the wrapper is retained for App-level cascade integration tests in mode.rs / completion.rs / options.rs). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.2 describe-/list- batch through the F.1 DisplayBuffer pipe: migrates Effect::DescribeCommand / Effect::Apropos / Effect::DescribeKey / Effect::ListKeymap to Editor::handle_effect. Adds four host-side content builders (Editor::build_describe_command_content — fallible: pushes echo on unknown name; build_apropos_content — fallible: pushes echo on empty pattern; build_describe_key_content — infallible; build_list_keymap_content). Relocates resolve_command_name_or_alias from lattice-ui-tui::app to lattice_host::excommand alongside aliases() (in-module test updated to the fully-qualified path). App-side do_describe_command and do_describe_key retained as thin renderer-side wrappers (3-line each: call host builder, route through display_buffer) because in-App callers exist outside the Effect dispatcher (HelpLinkTarget::Command / HelpLinkTarget::Chord follow handlers + cmdline <C-h>); do_apropos and do_list_keymap delete entirely (Effect-only path). Stale imports cleaned (crate::help::{command_link, key_link} + resolve_command_name_or_alias from super). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.3 option/event introspection batch through the F.1 DisplayBuffer pipe: migrates Effect::DescribeOption / Effect::ListOptions / Effect::DescribeOptionResolution / Effect::DescribeEvents / Effect::DescribeEvent to Editor::handle_effect. Adds five host-side content builders (Editor::build_describe_option_content — fallible: pushes E518 on unknown; build_list_options_content — infallible: walks OPTION_DECLS / GROUP_DECLS linkme slices; build_describe_option_resolution_content — fallible: walks the §6.1 layer model marking ⭐ on contributing layers via editor.mode_registry + editor.active_modes + editor.buffer_local_overrides; build_describe_events_content — infallible: walks EVENT_DESCRIPTORS linkme slice grouped by source crate; build_describe_event_content — fallible: descriptor lookup). App-side do_describe_option / do_list_options / do_describe_option_resolution delete entirely (Effect-only). do_describe_events / do_describe_event retained as thin 3-line #[allow(dead_code)] wrappers — prod path is Effect-driven; tests in app/mode.rs invoke them directly to assert renderer-side display routing. Stale imports cleaned (crate::help::HelpContent from app/options.rs). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. Post-F.3 scope review. Audited the remaining deferred items against the GPUI-conflict lens; design doc revised with three buckets — (A) correctly deferred (visible_highlights cache + runtime loop + Renderer trait — TUI-specific by design), (B) misclassified as deferred → pulled back in (apply_edit_blocking + edit-cluster Effects, activate_buffer + buffer-nav Effects, mode lifecycle, remaining describe- family), (C) thin wrappers that are correctly App-local (cmdline <C-h> + help-link follow handlers). The F.1-F.3 DisplayBuffer signal-pipe pattern makes the bucket-B items tractable as RendererSignal::* emissions in ways they weren't when the original scope was written. Revised slice plan: F.4 (activate_buffer chain), F.5 (mode lifecycle), F.6 (remaining describe-: describe-mode/list-modes/customize), F.7 (list-diagnostics), E.7 resurrected (apply_edit_blocking + edit cluster via RendererSignal::EditsApplied(Vec<EditDelta>)), G (collapse), H (cleanup). 5.5.F.4.1 snapshot/load helper migration (pure-editor plumbing): relocates snapshot_active_pane (~37 LoC), snapshot_active_document (~25 LoC), and load_active_pane (~15 LoC) from lattice-ui-tui::app::lifecycle to lattice_host::dispatch::Editor. All three bodies were 100% editor.* reads + writes (no helper calls, no render coupling); migration is mechanical substitution self.editor.Xself.X inside the new impl Editor block. App-side methods become 1-line delegates so the 23 ui-tui call sites across 8 files compile unchanged. Unblocks F.4.2 (activate_buffer_state host-side — depends on the snapshot helpers being reachable from inside Editor methods). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.4.2 activate_ dispatch family migration: relocates activate_buffer (~25 LoC dispatch entry), activate_document (~120 LoC), activate_file_tree (~25 LoC), activate_oil (~15 LoC), activate_help_in_pane (~60 LoC) from lattice-ui-tui::app::lifecycle to lattice_host::dispatch::Editor. Also co-relocates push_position_history from app::motions (~28 LoC, pure-editor; called by activate_buffer + activate_help_in_pane) and the POSITION_HISTORY_CAP constant. Total ~273 LoC moved. activate_buffer and activate_document return booltrue signals the caller to run activate_buffer_state() next (the App-side tail that still calls activate_major_for_buffer_kind + maybe_reparse_syntax; both migrate when F.5 lands mode lifecycle). The bool is explicit cross-migration-window coordination, not a permanent signal-variant — it deletes when F.5+ brings the tail host-side. App's activate_buffer and activate_document become 2-line delegates (if self.editor.activate_X(id) { self.activate_buffer_state(); }); activate_file_tree / activate_oil / activate_help_in_pane collapse to 1-line delegates. The PrevPaneState import in app::lifecycle (only used by the migrated activate_help_in_pane body) drops. The 23 + 30 = ~53 ui-tui call sites across 8 + N files compile unchanged through the delegates. Unblocks F.4.3 (the Effect arms BufferNext / BufferPrev / BufferDelete). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.4.3 buffer-nav Effect migration (partial — defers BufferDelete to F.4.4 due to lsp_close_buffer coupling): relocates next_listed_buffer_id, prev_listed_buffer_id, listed_buffer_ids_sorted (all pure-editor — only call editor.buffers.listed_ids_sorted + editor.active_pane_buffer_id) plus do_buffer_next / do_buffer_prev to lattice_host::dispatch::Editor. Both do_* methods return bool mirroring activate_buffer's shape (true when the activation went through the full document-activation path, i.e. activate_buffer_state tail still needs to run). Effect::BufferNext / Effect::BufferPrev arms migrate to Editor::handle_effect; on true return, the arm pushes the new RendererSignal::BufferActivated variant. App's handle_renderer_signal::BufferActivated arm runs self.activate_buffer_state() — exactly the same App-side tail F.4.2's bool-return wrappers run inline. RendererSignal::BufferActivated is transient. It deletes (or transforms) in F.5 once mode lifecycle + the syntax cluster move host-side, leaving only the renderer-coupled visible_highlights.clear() / pane_highlights.clear() tail (genuinely TUI-specific, Bucket A in the post-F.3 review). App-side do_buffer_next / do_buffer_prev / next_listed_buffer_id / prev_listed_buffer_id / listed_buffer_ids_sorted delete entirely (Effect-only paths; no direct callers). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.4.4 do_buffer_delete + Effect::BufferDelete migration: relocates do_buffer_delete (~60 LoC) and lsp_close_buffer (~6 LoC) bodies from lattice-ui-tui::app::lifecycle / app::lsp to lattice_host::dispatch::Editor. The lsp_close_buffer audit landed clean — both editor.buffer_uris (HashMap) and editor.lsp (LspSupervisorHandle fire-and-forget cmd channel) are already host-side fields, so the close path is a pure mechanical port with no signal/coordination machinery needed. Editor::do_buffer_delete returns bool matching do_buffer_next / do_buffer_prev's shape — true when the successor activation went through the full document-activation path (caller runs activate_buffer_state tail). Effect::BufferDelete { force } arm migrates to Editor::handle_effect, reusing the existing RendererSignal::BufferActivated (added in F.4.3); App's apply_effect folds it into the grouped no-op. App-side do_buffer_delete wrapper deletes entirely (zero non-Effect callers); lsp_close_buffer wrapper kept as a thin delegate because the App-tick drain_lsp_detach_events path still calls it (deletes when tick-event drain moves host-side). Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.5 mode-lifecycle migration: 5 sub-slices retire RendererSignal::MirrorOptionToModes AND RendererSignal::BufferActivated, ending the renderer-side residue of mode dispatch. F.5.1 pure-editor dep prep — lsp_mode_enabled_for, recompute_active_completion_sources_for, path_for_bufferEditor; App-side becomes 1-line delegates so the existing call sites compile unchanged. F.5.2 mode-lifecycle core — activate_mode_by_id, deactivate_mode_by_id, maybe_auto_activate_lsp_mode, toggle_mode_by_nameEditor::dispatch. Each returns Vec<RendererSignal> because the inner drain_option_changes can fan typed-option writes from a mode's on_activate/on_deactivate (e.g. lsp-folding-mode swapping foldmethod=lsp); App-side wrappers fan the returned signals through handle_renderer_signal. F.5.3 kind/lang activation + lifecycle drain — activate_major_for_buffer_kind and drain_mode_lifecycle_events (M-async.3) → Editor; both return signals, both App-side wrappers fan. F.5.4 option-cascade closure — mirror_option_to_modesEditor; apply_option_cascade calls it synchronously and signals.extend(...)s the result instead of emitting RendererSignal::MirrorOptionToModes. The variant retires from the enum; the App-side handle_renderer_signal arm and App::mirror_option_to_modes wrapper delete entirely. F.5.5 post-activation tail — activate_buffer_stateEditor (returns signals). Effect::BufferNext / BufferPrev / BufferDelete arms in Editor::handle_effect now run the full tail inline (out.renderer_signals.extend(editor.activate_buffer_state())); the transient RendererSignal::BufferActivated retires from the enum and the App-side handle_renderer_signal arm deletes. The renderer-coupled visible_highlights / pane_highlights cache clear is plain Editor-field writes (Bucket A — fields live host-side), so no replacement signal is needed. App-side activate_buffer_state wrapper collapses to a 4-line delegate that fans the returned signals — its three direct callers (activate_document, activate_buffer, :e-fresh-file path) compile unchanged. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅ at every sub-slice boundary. 5.5.F.6 describe-mode / list-modes / customize migration through the F.1 DisplayBuffer pipe: relocates do_list_modes (~57 LoC), do_describe_mode (~70 LoC), do_customize_picker (~70 LoC), do_customize_group (~45 LoC), do_customize_mode (~75 LoC), and the shared append_customize_row (~45 LoC) from lattice-ui-tui::app::help to lattice_host::dispatch::Editor as five content builders (build_list_modes_content — infallible; build_describe_mode_content — fallible, pushes echo + returns None on unknown name; build_customize_picker_content — infallible; build_customize_group_content — fallible; build_customize_mode_content — fallible) + the host-side append_customize_row (private helper reused by group + mode views). Effect::ListModes / Effect::DescribeMode { name } / Effect::Customize { name } arms migrate to Editor::handle_effect; each emits RendererSignal::DisplayBuffer with the appropriate BufferDisplayCategory (HelpList for list-modes / customize-picker / customize-group / customize-mode; HelpDescribe for describe-mode). App's apply_effect folds the three Effect variants into the grouped no-op. App-side wrappers retained: do_describe_mode and do_customize stay as thin 3-line #[allow(dead_code)] wrappers because the HelpLinkTarget::Mode / HelpLinkTarget::Customize follow-handlers call them directly outside the Effect-arm dispatch path (analogous to F.2's do_describe_command / do_describe_key wrappers). do_list_modes, do_customize_picker, do_customize_group, do_customize_mode, and append_customize_row delete entirely (Effect-only or sub-routine, no direct App callers). do_customize_edit (cmdline-prefill, M.9.2) stays App-side — it mutates command_line + modal which is renderer-coupled cmdline state. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.F.7 :list-diagnostics migration: relocates do_list_diagnostics (~46 LoC) from lattice-ui-tui::app::lsp to lattice_host::dispatch::Editor. The body was already 100% editor.* reads/writes (editor.pending_tag_origin, editor.lsp_diagnostics.snapshot(), editor.picker = Some(_)) plus three external calls (lattice_lsp::actor::uri_to_path — pure; lattice_help::one_line — pure; lattice_picker::Picker constructor — host-accessible). Migration is mechanical substitution. Different signal shape from F.1–F.6: this Effect produces a picker, not a help buffer; the picker is a renderer-neutral Editor field that the renderer reads each frame, so no RendererSignal is required — the host just writes self.picker = Some(_). Effect::ListDiagnostics arm migrates to Editor::handle_effect as a single editor.do_list_diagnostics() call; App's apply_effect folds the variant into the grouped no-op. App-side wrapper retained as #[allow(dead_code)] 1-line delegate because three test callers (app.rs:2640, lsp.rs:10457, lsp.rs:10481) invoke it directly outside the Effect dispatcher. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.E.7 (resurrected — see post-F.3 scope review) apply_edit_blocking / handle_edits cluster fully migrated host-side across 7 sub-slices. E.7.1 shift_highlights_for_edit (Bucket A audit revealed the body is pure-Editoreditor.scroll + editor.visible_highlights reads/writes; no RendererSignal::EditsApplied needed) + private shift_spans_within_lineEditor; App-side becomes 1-line delegate then deletes in E.7.2 once publish_document_changed migrates. E.7.2 publish_document_changedEditor; App-side wrapper retained for the 5 prod call sites until E.7.3+E.7.7 close them; App::shift_highlights_for_edit wrapper deletes (zero callers). E.7.3 apply_edit_blocking, apply_edit_batch_blocking, undo_blocking, redo_blocking, private apply_edit_to_oilEditor; App-side wrappers collapse to delegates; App::apply_edit_to_oil deletes (cac91be). E.7.4 do_delete_line (vim's :d / :g/.../d — full-line delete including trailing newline) → Editor; Effect::DeleteCurrentLine arm migrates to Editor::handle_effect; App's apply_effect folds into grouped no-op. E.7.5 do_substitute (vim's :s/pat/repl/[g]) → Editor — pulled fancy-regex as a direct lattice-host dep (matches lattice-core::search usage); Effect::Substitute arm migrates; App-side do_substitute collapses to #[allow(dead_code)] delegate (no remaining callers post-arm-collapse); stale Edit import drops from app/search.rs. E.7.6 planner-only migration for do_global: extracts build_global_targets (pattern validation + addressable-line scan + zero-match echo) → Editor. The body-replay loop stays App-side until the Effect router fully migrates host-side — a not-yet-migrated body effect would be a silent no-op via Editor::handle_effect alone, since App::apply_effect still owns the full router; once G.x retires App::apply_effect, the loop joins the planner host-side. E.7.7 handle_edits chokepoint → Editor; cursor settles to first.original_range.start then routes through Editor::publish_document_changed. Effect::Edits(edits) arm migrates to Editor::handle_effect; App's apply_effect folds into grouped no-op. App-side handle_edits + publish_document_changed wrappers delete entirely (zero callers). Stale AppliedEdit imports drop from app/dispatch.rs + app/lifecycle.rs. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green ✅. 5.5.G (in progress — sub-sliced because App::apply body is still ~420 LoC after F.7; the original "trivial collapse" framing assumed E covered the LSP / completion / motion clusters, but Bucket-A audits in F kept most of those App-side). G.1 ✅ pure-editor fold / macro / snippet arms: eight fold arms (OpenFoldAtCursor, CloseFoldAtCursor, ToggleFoldAtCursor, OpenAllFolds, CloseAllFolds, DeleteFoldAtCursor, GotoNextFold, GotoPrevFold), two macro arms (StartMacroRecord, StopMacroRecord), and SnippetLeave migrate to Editor::dispatch. Helpers do_set_fold_state_at_cursor, do_set_all_folds, do_goto_fold, do_delete_fold_at_cursor, do_start_macro_record, do_stop_macro_record migrate to Editor; private selection-rule fns innermost_fold_idx, fold_to_close_at, outermost_fold_idx co-move. App-side helpers delete entirely (zero callers — tests exercise the arms via app.apply(Action::*), not via direct method invocation). G.2 ✅ visual + mark (4 arms: EnterVisual/ExitVisual/ReselectLastVisual/SetMark; do_enter_visual/do_reselect_visual to Editor, do_exit_visual kept as delegate for 4 App callers). G.3 ✅ edit-cluster (10 arms: Undo/Redo/JoinLines/ToggleCaseAtCursor/EnterAppend/OpenLineBelow/OpenLineAbove/OverwriteChar/ReplaceUndoLast/DeleteCharBackward; 8 helpers + previous_position migrate; App's redo_blocking wrapper picks up #[allow(dead_code)]). G.4 ✅ scroll / viewport / page / bracket / redraw (8 arms; 6 helpers + bracket-scan fns + auto_open_folds_at_cursor migrate; the latter kept App-delegate for 7 callers). G.5 ✅ pane-navigation (6 arms; 5 helpers migrate; do_close_pane+activate_pane retained as App delegates because do_quit calls them. Note: G.5 originally targeted completion but reverted — do_completion_next/prev call App-side refresh_docs_popup_for_selection → LSP-coupled do_completion_resolve_focused). G.6 ✅ mark-history walk (2 arms; do_mark_history + do_walk_history migrate; App-side do_walk_history retained as delegate because do_jump_history still calls it — jump-history blocked on pop_popup_back migrating). G.7 ✅ tag-stack + mark-jump + popup-back-stack + jump-history (5 arms: TagStackPop/JumpToMarkLine/JumpToMarkExact/JumpHistoryBack/JumpHistoryForward; seed_help_metadata_locals + pop_popup_back migrate which unblocks the full jump-history walk + popup-back-stack pop; App-side wrappers retained as 1-line delegates for multi-call-site survivors). G.8 ✅ snippet placeholder nav (2 arms: SnippetNextPlaceholder/SnippetPrevPlaceholder; expand stays App-side pending active_language_id + is_word_char_byte migration). G.9 ✅ paste cluster (3 arms: PasteAfter/PasteBefore/PasteText; do_paste* + read_register migrate; do_paste+do_paste_text retained as delegates for app/picker.rs paste-picker accept + bracketed-paste runtime hook). G.10 ✅ search-state cluster (7 arms: SearchAppend/SearchBackspace/SearchSubmit/SearchCancel/SearchNext/SearchPrevious/SearchWordUnderCursor; preview_search/submit_search/cancel_search/repeat_search/do_search_word_under_cursor migrate; free fns compile_search_pattern/step_byte/is_word_char_byte duplicated host-side — App copies stay for the substitute-preview path; Action::FindRepeat defers — calls run_invocation). G.11 ✅ picker append/select + close-hover (5 arms: PickerAppend/PickerBackspace/PickerSelectNext/PickerSelectPrev/CloseHover; bump_live_picker_debounce + preview_picker_selection migrate; latter returns Vec for activate_buffer_state fan-out). G.12 ✅ help-dismiss (1 arm: HelpDismiss; dismiss_file_tree migrates returning Vec; App-side delegate retained for Effect::CloseFileTree apply_effect arm). Cumulative G.1–G.12: 64 arms migrated; App::apply body ~420 LoC → ~205 LoC. Tests: 1419 ui-tui + 185 host + 180 lsp = 1784 green at every boundary. G.7+ deferred clusters (each blocked on App-side helpers migrating, each comparable in size to E.7 / F.5): Insert+EnterMode (LSP-trigger autopilot + replicate_block_insert), Completion family (refresh_docs_popup_for_selection + refilter_insert_completion + LSP resolve), Picker accept/dismiss (file open + SMR queue + preview restore), Help/Oil/FileTree follow+dismiss, Search family (preview_search/submit_search/etc.), CommandLine family (entangled with completion popup + substitute preview), LSP request arms (~12 — each routes through a do_lsp_* helper), Snippet expand, TagStackPop, PlayMacro+PlayLastMacro, JumpHistoryBack/Forward, JumpToMark*, Paste family (read_register App-side), RepeatLastChange, EnterBlockVisual*, Invoke. G.final (true App::apply collapse) becomes mechanical once App-side helpers all delegate — every remaining arm is renderer-neutral in principle but reaches into App-side machinery whose migration is the rest of the G.x series. 5.5.H (vestigial cleanup — remove the dead-code App delegates that have no remaining callers) requires G.final and stays pending.
5.6⛔ (revised)Pane-provider lookup → host. Replaces the original 5.5 trait-object plan with the small mechanical move that fits Option-E composition. Mode-walking resolution (walk active minors then major) moves to lattice_host::pane_render::lookup(&impl ProviderLookup, &Editor, BufferId) -> Option<&Provider>. Each renderer keeps its concrete typed registry (no trait objects on the paint hot path). ~1-2 hours.
5.7lattice-ui-gpui scaffold. New crate. Window opens. Compose GpuiApp { editor: Editor, gpui_theme, gpui_pane_render_registry, ... }. Render a placeholder. Validates end-to-end that the host substrate is reusable without ui-tui in the dep tree. ~1-2 weeks.
5.8+🟡 in progressGPUI feature parity + architectural async enforcement. Sub-slices 5.8.AA through 5.8.AF have shipped iteratively: host migration of pickers, LSP request fan-out, popup unification, picker accept/dismiss closeout, every renderer-neutral Effect arm. Phase 5.8.AF.5 (the active sub-phase) takes the next architectural step -- relocating every UI-thread drain off the renderer onto background tokio tasks via RenderState + PerBufferCache (paramount goal #4 enforcement). See Phase 5.8.AF.5 — UI-thread relocation below for the slice ledger.
5.9lattice-cli --gui / --tui flags + macOS app bundle + desktop identity. --gui and --tui flags plumbed in lattice-cli via clap (mutually exclusive via conflicts_with); use_gui = cli.gui || (running_in_bundle && !cli.tui) resolves final routing. Window title set via TitlebarOptions { title: Some(SharedString::from("Lattice")), .. } in lattice-ui-gpui::window::run(). Linux desktop identity: app_id = Some("com.lattice-editor.lattice") in WindowOptions for XDG taskbar / WM grouping. macOS .app bundle: [package.metadata.bundle] in lattice-cli/Cargo.toml; assets/lattice.icns generated from assets/lattice.iconset/ (10 sizes: 16–512 + @2x variants) via iconutil; build with cd crates/lattice-cli && cargo bundle --features gui. Bundle-context auto-detection (#[cfg(all(target_os = "macos", feature = "gui"))]): when binary runs inside …/Contents/MacOS/, std::env::current_exe() inspects path components for "Contents" and auto-routes to GPUI without --gui; --tui overrides. running_in_bundle = false fallback on non-macOS / non-gui builds — no dead-code impact. Linux XDG: assets/linux/com.lattice-editor.lattice.desktop for system-wide cp install. Commit: b8eb60d. Default stays TUI for direct CLI invocations; open Lattice.app auto-selects GUI via bundle-context guard.
old 5.5 (trait-objected PaneRenderer in host)droppedSuperseded by the Option-E pivot. The registry is correctly renderer-specific; only the lookup is host. The revised 5.6 captures the actual remaining work.
old 5.6 (lattice-render crate)droppedratatui's pull-based draw and GPUI's retained-mode element tree are structurally different; a common Frame / InputEvent / LayoutConstraints surface buys nothing and constrains both renderers. Revisit only if a real shared primitive emerges (likely §5.6.7's DocumentRenderer — would live in lattice-host::ui, not a separate crate).
5.9+Real GPUI: text rendering, atlas, panes, input, popups. Multiple sub-slices.
5.lastFlip default to GPUI. Separately scheduled, after parity + a release cycle as opt-in.
SliceStatusCommitWhat landed
18d8c5b2LSP file watcher off the UI thread. LspFileWatcherHandle { cmd_tx } on Editor; a tokio task on the LSP runtime owns notify::RecommendedWatcher, the event rx, and the per-server subscription map. Editor::refresh_lsp_file_watcher becomes a fingerprint-gated non-blocking cmd-send; drain_lsp_fs_events deletes. notify API calls wrapped in tokio::task::block_in_place so a giant target/ walk doesn't stall sibling LSP tasks.
2a32b4eb.gitignore filtering at the notify callback. WatcherIgnoreMatcher combines per-root Gitignore + baked default globs (**/target/**, **/.git/**, **/node_modules/**, …). Shared via Arc<ArcSwap<...>> between the notify callback and the watcher task; events whose every path is ignored never enter the channel. 4 new tests on the matcher. Same ignore crate ripgrep/helix/zed use.
3ab6cd547 + e3d4497 + 0ffd767 + f9310e2RenderState read contract. New lattice-host::render_state module with RenderState + 11 sub-states (active_document, buffers, panes, lsp, syntax, picker, completion, popup, messages, modeline, diagnostics). Editor::render_state: Arc<ArcSwap<RenderState>> published at every dispatch + handle_effect tail via build_render_state (struct-update form RenderState { override, ..Default::default() }). Proof-of-life migration: TUI severity_for_line and GPUI line_severity switch from editor.lsp_diagnostics direct reads to rs.diagnostics.layer.line_severity(...). 4 new tests.
3b.07f51492document_highlight drain off the UI thread (template for single-slot per-subsystem migration). Editor.lsp_document_highlights flips from Option<DocumentHighlightCache> to Arc<ArcSwapOption<DocumentHighlightCache>>. The spawned LSP request task clones the Arc and writes directly via .store() when the response arrives; no channel, no drain. pending_document_highlight_rx field + drain_pending_document_highlight method delete entirely. LspRenderState.document_highlights carries a clone of the cache slot Arc — renderer reads through rs.lsp.document_highlights.load_full() see the task's writes without re-publication. 1 new test locking the cross-clone invariant.
3b.1aaf5980inlay_hint + folding_range off the UI thread (template for per-buffer HashMap<BufferId, T> migration). Introduces the PerBufferCache<T> primitive in lattice_host::per_buffer_cache with the PerBufferCacheExt trait (get_for / insert_for / remove_for). Both cache fields flip from raw HashMap to PerBufferCache<T>. Drains + pending_*_rx fields delete; the folding-range drain's recompute_folds() side-effect now fires from maybe_request_folding_range on cache-version flip (tracked via new last_recomputed_lsp_fold_version field). LspRenderState gains inlay_hints + folds. 4 new tests on PerBufferCache.
3b.2de49271semantic_tokens off the UI thread. Same 3b.1 template: PerBufferCache<SemanticTokensCache>, spawned task writes via insert_for, drain deletes, renderer reads through rs.lsp.semantic_tokens.get_for(...). Pure-helper extraction: Editor::apply_semantic_tokens_delta_outcome shared between the spawned task body and the unit tests so the cross-clone invariant has one source of truth.
3b.3f574edecode_lens off the UI thread + PerBufferCacheExt::retain. Code-lens cache migrates by the same template. The migration surfaced a missing primitive: retain (drop entries matching a predicate) needed by the cache eviction path. Added with a two-pass implementation that skips the Arc realloc when nothing matches — cheap on the hot path.
3b.40762596document_link + document_color off the UI thread. Two more caches by the template. LspRenderState gains both.
3b.5f25f9a2pull_diagnostics off the UI thread. Last of the per-buffer LSP caches. Shared Editor::apply_pull_diagnostics_outcome pure helper bridges the spawned task and tests. With 3b.5 done, every LSP feature cache routes through PerBufferCache<T> or Arc<ArcSwapOption<T>>; the renderer's hot-loop drain phase carries no LSP work.
3b.signals⛔ plannedSignal-emitting drains (hover, signature_help, definitions, references-as-picker, etc.). Same template plus the RendererSignal::DisplayBuffer pipe stays in place; the channel rx + drain moves off-thread, the signal emission stays.
3b.interactive⛔ plannedPicker + completion + messages drains. User-interactive state machines; pattern same as 3b.* but the state types are richer.
3c.0f896ee6Editor-actor scaffolding (dormant). New lattice-host::editor_actor module defining EditorCommand (Apply / HandleEffect / DispatchBlocking / Tick / Ping / Shutdown) and EditorActorHandle. spawn_editor_actor dedicates a thread named lattice-editor running a current_thread tokio runtime that owns the Editor and processes commands serially. signal_tx fan-outs RendererSignals emitted by DispatchOutcome cascades. 5 substrate tests (ping/pong, apply→publish, drop-joins-thread, explicit shutdown idempotent, tick-with-no-pending-work).
3c.1ec48195ActiveDocumentRenderState populated. The struct gains 9 fields (buffer_kind, document_buffer_id, active_pane_buffer_id, cursor, scroll, viewport_height, modal, visual_anchor, snapshot). Editor::build_render_state populates them from the editor's current values; every dispatch / handle_effect tail publishes. This is the read-side contract for the 3c.atomic.* sub-series.
3c.2 prep750e51fApp::ad() accessor. Returns Arc<ActiveDocumentRenderState> via self.editor.render_state.load().active_document.clone(). Renderer code migrates to app.ad().X instead of app.editor.X for active-document state.
3c.atomic.A578d557App.render_state seam. App gains its own render_state: Arc<ArcSwap<RenderState>> field, cloned from the editor at App::new time. App::ad() reads through self.render_state instead of self.editor.render_state. Today the two Arcs are the same cell, so observed values match byte-for-byte; the slice is purely the structural seam that lets later slices flip the writer to an EditorActorHandle without disturbing reader call sites.
3c.atomic.B3e3347frender.rs production reads → app.ad(). 58 reads of app.editor.cursor / .scroll / .modal / .document_buffer_id in render.rs (lines 1–3928, before the test module) migrate to read through the renderer-owned cell. Adds three publish points: App::new tail (primes render-state at boot), App::activate_buffer (host activation mutates active state outside dispatch), and 8 test fixtures that mutate app.editor.* directly.
3c.atomic.B.191eefb4runtime.rs modal reads → app.ad(). Extends 3c.atomic.B to the TUI main loop's four reads of app.editor.modal (three for per-frame cursor-style escape sequences, one for TranslateContext). Other fields the input loop reads (builtins, pending_count, picker.is_some(), etc.) stay on the editor field; not yet on ActiveDocumentRenderState.
3c.atomic.Cd51ac6aTyped write-side setters on Editor. Adds set_cursor / set_cursor_line / set_cursor_byte / set_scroll / set_modal — each wraps a field write + publish_render_state(). Tests replace editor.cursor = p; editor.publish_render_state(); with editor.set_cursor(p); the seam artifact collapses. The setters define the OUT-OF-DISPATCH write surface; in-dispatch writes stay as raw field writes (the tail already publishes).
3c.atomic.D933837ehighlights.rs renderer-adjacent reads + set_viewport_height publish. refresh_highlights (&mut self, post-dispatch) and highlights_for_buffer_line (pure renderer-side) read document_buffer_id / scroll / viewport_height through self.ad(). The migration surfaced a real publish gap: App::set_viewport_height mutated editor state outside dispatch without publishing — invisible until 2 readers migrated, fatal once they did. Fixed at source.
3c.atomic.E9fe7337Combined doc-id + buffer-kind reads → app.ad(). Three app/lifecycle.rs helpers (document_folds_for, document_last_parsed_text_version_for, document_last_synced_syntax_version_for) — each guards on id == doc_id && active_buffer == Document. ad().buffer_kind is the published mirror of editor.active_buffer, so the pair reads atomically via a single self.ad() call. Also migrates motions.rs::help_popup_inner_height and mode.rs::modal_label. Surfaced two more publish gaps: App::open_popup / open_floating_popup wrappers + test_helpers::install_help; all fixed at source.
3c.atomic.F51c173fEditorActorHandle typed setter commands. EditorCommand enum gains 6 variants (SetCursor / SetCursorLine / SetCursorByte / SetScroll / SetModal / SetViewportHeight). EditorActorHandle gains matching convenience methods (handle.set_cursor(p) etc.). The actor's run_actor body dispatches each to the in-process setter; the renderer thread observes the new state via its shared Arc<ArcSwap<RenderState>> clone. 5 new actor tests. The actor remains dormant — App still owns an Editor directly — but the API gap between the in-process and actor-mediated write surfaces is now closed.
3c.atomic.*⏳ in-flightRemaining App-side reads. app/lsp.rs (~10 production reads inside spawned tasks; need task-boundary analysis), inside-dispatch reads (correct as-is, kept on editor.X), and runtime-loop reads of fields not yet on ActiveDocumentRenderState (builtins, pending_count, op_count, macro_recording, completion_state, picker). Either widen the render-state surface or migrate through synchronous Get<T> actor commands; each cluster decides on its own.
3c.final🔄 in progressEditor on its own dedicated thread. Plan revised in docs/dev/archive/3c-final-editor-thread.md (2026-05-20). The earlier EditorActorHandle mechanical-swap shape is superseded by a dedicated-thread design: std::thread::spawn consumes Action values from an mpsc::UnboundedSender; the renderer holds the channel + Arc<ArcSwap<RenderState>> and never gets &mut Editor. Compile-time enforcement of paramount goal #4. Sliced A–G; the X-series predecessor work (worker, idle bridge, EditorElement) already decoupled the read path.
3c.final.Aaudit onlyRenderer ↔ Editor read/write audit. Walked every renderer-thread editor.* access in crates/lattice-ui-gpui + crates/lattice-ui-tui (500 raw grep hits; ~120 distinct production sites after filtering test fixtures). Classified each as A (already-on-RenderState) / B (move-able) / C (mutation→Action) / D (sync-answer holdout) / N (App-side helper, moves with slice F). Enumerated the slice-B field set (10 sub-states, ~30 new fields) and the slice-C action additions (6 scalar variants). All sync-return holdouts resolved without reply-channels — every D-case lifts cleanly to RenderState. Output: docs/dev/archive/3c-final-audit.md.
3c.final.B.1Panes + buffers RenderState foundation. PanesRenderState gains tree: Arc<PaneTree>; BuffersRenderState gains registry: BufferRegistry (Arc-bump clone) + uris: Arc<HashMap<BufferId, Uri>>. Editor::build_render_state populates both. Three new render-state tests assert publication round-trips (pane-tree active index / leaves, registry clone observes post-publish inserts, URI map round-trip). Three new App accessors (App::panes() + App::buffers() in TUI, mirroring the existing app.ad() shape) plus a parallel pattern via rs_guard in the GPUI peer. All production reads of app.editor.pane_tree.* / app.editor.buffers.* / app.editor.buffer_uris.* in render.rs + window.rs migrated atomically. Workspace check + lattice-host (247) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green; both benches (highlights_worker, editor_element_frame) compile clean.
3c.final.B.2Active-document decoration fields onto ActiveDocumentRenderState. Seven new fields land: folds: Arc<[Fold]>, all_matches: Arc<[Range]>, current_match: Option<Range>, visual_range: Option<Range>, substitute_preview: Option<Arc<SubstitutePreview>>, selections: Arc<SelectionSet>, option_cache: OptionCache. Build path populates them; two new tests (active_document_folds_reflects_editor_state, active_document_search_and_options_reflect_editor_state) lock the round-trip contract. Renderer migration: GPUI paint_pane reads folds/visual/match/substitute from rs_guard.active_document and inlines the line_inside_closed_fold + fold_start_at predicates (the host helpers still exist for dispatch-side use). TUI FrameView::from_app + for_buffer swap the prior Arc::from(Vec::clone) to a one-Arc-clone off rs.active_document.folds; every app.editor.{all_matches,current_match,option_cache,substitute_preview,visual_selection_range(),document.selections()} read in production code now goes through app.ad().X. Two host-side mutators (set_selections_blocking, toggle_mode_by_name) gain tail publish_render_state() calls — they were called from out-of-dispatch sites (Visual extension, gv-reselect, mode cascades, tests) without republishing. Four direct-mutation tests gained manual publishes after editor.folds.push / editor.folds[i].closed = true / editor.recompute_folds(). Workspace check + lattice-host (249) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.B.3Picker + completion + popup substates populated. PickerRenderState gains state: Option<Arc<Picker>>; CompletionRenderState gains insert: Option<Arc<InsertCompletionState>> + state: Option<Arc<CompletionState>>; PopupRenderState gains buffer_id, help: Option<Arc<HelpBuffer>>, help_highlights: Arc<[Vec<StyledSpan>]>, placement: PopupPlacement, plus an is_open() convenience. Editor::build_render_state populates each via Option::map(Arc::new) (zero-cost when closed). Two new tests (popup_substate_defaults_closed, picker_and_completion_substates_default_closed). Three new App accessors App::picker_state(), App::completion(), App::popup() mirror the existing ad() / panes() / buffers() shape. Migrated TUI production reads of app.editor.{picker,completion_state,popup_buffer,insert_completion,popup_placement} to the published substates — bound each substate Arc to a local so as_deref() borrows survive the function body (six sites). GPUI peer migrates paint_pane's popup gate, the picker_strip_rows computation, both picker overlays, and the popup overlay to read via render_state.{picker,popup} substates directly. Scope cut for follow-up: the TUI's App::popup_help() wrapper still delegates to self.editor.popup_help() rather than reading rs.popup.help; migrating it requires adding publish_render_state() to every popup-mutation path (open / scroll / dismiss / follow-link / customize-pickers / …) which would balloon the slice. The published substate IS consumed directly by the GPUI peer; the TUI wrapper migration is queued as 3c.final.B.3b. Workspace check + lattice-host (251) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.B.4LSP progress + supervisor handle into LspRenderState. Two new fields: progress: Arc<HashMap<(Arc<str>, String), LspProgressUpdate>> (small per-publish HashMap clone, ~10 concurrent items typical) and supervisor: LspSupervisorHandle (internally Arc<ArcSwap<SupervisorSnapshot>>-backed so Clone is one Arc bump). Editor::build_render_state populates both. New test lsp_progress_reflects_published_map locks the round-trip. Migrated the two TUI production reads in render.rs's LSP modeline indicator: app.editor.lsp.servers_for(uri)rs.lsp.supervisor.servers_for(uri) and &app.editor.lsp_progress iter → rs.lsp.progress.iter(). Workspace check + lattice-host (252) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.B.5TranslatorRenderState — closes the audit's slice-D TranslateContext holdout. New top-level sub-state carrying builtins: lattice_grammar::builtins::Builtins (Copy), keymap: KeymapHandle (Arc-backed; Clone is one Arc bump), partial_chord: Arc<[KeyChord]> (small slice, 0–2 entries typical). RenderState gains a translator: Arc<TranslatorRenderState> field. Editor::build_render_state populates it via the cheap-clone pattern. New test translator_substate_reflects_editor_inputs exercises a non-empty partial_chord round-trip. runtime.rs's TranslateContext construction now binds let translator = app.editor.render_state.load().translator.clone(); once per keystroke and threads &translator.{builtins,keymap,partial_chord} into the context — the three &'a borrows that previously tied the translator to Editor's lifetime are gone. This is the structural prerequisite for slice E (Editor on its own thread): the renderer's input loop no longer needs &app.editor.X to translate a keystroke into an Action. Workspace check + lattice-host (253) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.B.6Lifecycle + theme finish slice B. New LifecycleRenderState carries should_quit, pending_redraw, terminal_width: Option<u16> (the per-tick "renderer should notice this" signals). Top-level RenderState gains a theme: Theme field (Theme is Copy — every field is Copy — so no Arc indirection). Editor::build_render_state populates both. New test lifecycle_and_theme_reflect_editor_state locks the round-trip. Renderer reader migrations: TUI main_loop's while !app.editor.should_quit becomes while !app.editor.render_state.load().lifecycle.should_quit; the pending_redraw check at the top of the loop reads via the same substate (the acknowledge-write stays on editor.X but gains a manual publish_render_state() so the next iteration's load observes the cleared flag — without that the loop would re-clear every frame). GPUI on_key_down reads should_quit via RS too. GPUI paint_pane rebinds host_theme from rs_guard.theme instead of editor.host_theme; the cursorline_bg read reuses the local binding. Write-track holdouts (terminal_width = Some(size.width), pending_redraw = false) remain on the editor field — slice C lifts them to Action::SetTerminalWidth + Action::AcknowledgeRedraw. Slice B is structurally complete: every group's enumerated field set from the audit (§5) lives on RenderState; reader-site migration is complete in production code paths apart from the deferred B.3b TUI popup-help wrapper. Workspace check + lattice-host (254) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.CRenderer non-dispatch mutations → Action::*. Six new variants on enum Action: SetViewportHeight(u32), EnsureCursorVisible, RefreshPaneHighlights, DismissPopup, SetTerminalWidth(u16), AcknowledgeRedraw. Each has a one-shot match arm in Editor::dispatch's top-level match that forwards to the existing host helper (editor.ensure_cursor_visible, editor.refresh_pane_highlights, editor.dismiss_popup) or sets the field directly (viewport_height, terminal_width, pending_redraw). The matching App-side wrappers (App::set_viewport_height, App::refresh_pane_highlights, GpuiApp::set_viewport_height, GpuiApp::ensure_cursor_in_viewport, GpuiApp::dismiss_popup) keep their signatures but now route through apply(Action::X) / dispatch_action(Action::X) instead of mutating editor state directly. The two raw field writes in runtime.rs::main_loop (editor.terminal_width = ..., editor.pending_redraw = false) become app.apply(Action::SetTerminalWidth(...)) and app.apply(Action::AcknowledgeRedraw). GPUI window.rs::render's self.app.editor.refresh_pane_highlights() becomes self.app.dispatch_action(Action::RefreshPaneHighlights). Dispatch tail publishes RS automatically — no manual publish_render_state() calls remain in renderer code for these paths. Workspace check + lattice-host (254) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.Dverification onlySync-answer holdouts: nothing left to migrate. The audit's §4 enumeration listed three D-bucket cases: per-buffer document handle lookup, per-buffer Oil view extraction, and the TranslateContext borrow batch. All three dissolved into RenderState lifts in earlier B groups: the document-handle + Oil-view lookups go through rs.buffers.registry.X() (slice B.1), and the translator inputs (builtins, keymap, partial_chord) live on rs.translator (slice B.5). No reply-channel infrastructure is needed for slice E.
3c.final.E.3Mid-size clusters migrated through the mutate_editor seam. Adds the same routing pattern across app/popup.rs (6 mut delegates: open_popup, open_floating_popup, dismiss_stale_popup_registry, seed_help_metadata_locals, swap_popup_content, dismiss_popup), app/options.rs (5 muts: rebuild_option_cache, recompute_options_for_buffer, recompute_active_completion_sources_for, do_set, drain_option_changes, apply_per_language_toml_overrides), app/cmdline.rs (2 muts: open_completion_popup, refresh_completion_popup), app/completion.rs (12 muts: snippet expand / next / prev / reload, completion next / prev / trigger / accept / toggle docs / cancel / filter clear / filter to source), app/help.rs (10 muts: do_open_help_topic, do_describe_command, do_describe_key, do_open_hover, do_describe_events, do_describe_event, do_describe_mode, do_customize, do_tutor, do_customize_edit), app/dispatch.rs (8 muts: run_tick_pending, run_oil_invocation, run_file_tree_invocation, run_help_invocation, run_read_only_motion, run_invocation + out-pattern, run_document_invocation + out-pattern, apply_effect's handle_effect), app/picker.rs (11 muts: apply_picker_outcome, snapshot_lsp_instances, open_lsp_locations_picker, open_lsp_picker, open_picker, drain_pending_picker_init, drain_pending_live_picker_query, seat_picker_from_pairs, open_buffer_picker, do_picker_dismiss, do_picker_accept), app/lifecycle.rs (10 muts: activate_file_tree, activate_oil, activate_help_in_pane, do_write, load_active_pane, snapshot_active_document, snapshot_active_pane, activate_buffer_state, jump_to_file_line_col, open_help_in_pane, seed_empty_document_locals, save_blocking). Pattern for &str / &Path args: clone to owned via .to_string() / .to_path_buf() for the Send + 'static closure, dereference inside. Pattern for &mut out: DispatchOutcome results: build out inside the closure, return it from mutate_editor_with. lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green at every checkpoint.
3c.final.E.cleanupWarning sweep + dead-code purge. After the E.swap landing, the dead_code lint surfaced ~50 warnings across both renderer crates + lattice-mode. Every warned item was an App-side delegate method (1-line mutate_editor calls) or a free helper function whose only callers are in #[cfg(test)] mod tests blocks — i.e., dead in the lib build by design (production routes around them through the seam) but live for test fixtures. The cleanup: (a) deleted GpuiApp::finalize_boot outright (its body was inlined into GpuiApp::new to run on the owned Editor before the actor spawns; apply_per_language_toml_overrides added to that flow to match the prior end-state); (b) added #![allow(dead_code)] at the top of lattice-ui-tui/src/app.rs with an explanatory comment, since 80% of the warnings cluster there — the alternative would have been 30+ individual #[cfg(test)] gates with no architectural benefit beyond what the swap already enforces; (c) per-item #[allow(dead_code)] on lattice-mode/src/locals.rs::LocalDyn::{as_any_mut, into_any} + BufferLocals::{get_mut, remove} — designed-in API surface called out in the audit but not yet wired to an owner mode; (d) removed an unused mut qualifier in an LSP test. Net warning count: 50 → 0 across lattice-host, lattice-ui-tui, lattice-ui-gpui, lattice-mode. cargo check --benches also clean. Plan for the B-extension perf slices (B.7 / B.8 / B.9 / B.10 / B.11) persisted to docs/dev/operations/3c-final-b-extension.md so the post-swap follow-up work isn't lost. lattice-host (257) + lattice-ui-tui (1392) + lattice-ui-gpui (23) tests green.
3c.final.E.swapSTRUCT SWAP LANDED — App.editor: Editor → App.editor_actor: EditorActorHandle (cfg-gated). The architectural pivot for paramount goal #4 is in: in production builds (cfg(not(test))) both renderer peers hold EditorActorHandle and reach Editor only through the actor handle's blocking RPCs (mutate_blocking, with_editor, mutate_blocking_with, apply_blocking); the &mut Editor type literally cannot escape the actor thread, enforced by the type system. Cfg-gating strategy: #[cfg(not(test))] pub editor_actor: EditorActorHandle + #[cfg(test)] pub editor: Editor. Test builds preserve direct Editor ownership so fixtures that mutate state without going through the dispatch path keep compiling — this is the audit §7 "test-internals escape hatch" formalised. Seam helpers (mutate_editor, mutate_editor_with, read_editor) are cfg-gated: prod bodies route through the actor handle's RPCs; test bodies retain direct closure invocation against the in-process Editor. Visibility flipped from pub(crate) to pub so benches (which compile as separate crates) can use the same seam. Constructor refactor: App::new / GpuiApp::new now apply boot-time setup directly on the owned Editor BEFORE spawning the actor — the App-side wrappers that previously routed through mutate_editor would funnel through the actor mailbox which doesn't exist yet at that point in construction. Renderer-side post-actor wiring (rebuild_tui_theme / rebuild_gpui_theme) runs against the App after the actor spawns; it reads the already-published RS, no editor borrow needed. Final missed sites cleaned up: app/options.rs (set_foldmethod_for_test, take_pending_structural_section, pending_structural_section_paths), app/popup.rs (popup_help_links, popup_help_anchors, popup_help_highlights, active_popup_placement — all test-only, gated #[cfg(test)]), pane_render.rs (pane_render_provider two-step refactor — read active mode chain through read_editor, then walk against the renderer-thread registry outside the closure since ProviderLookup isn't Send + 'static), render.rs::modeline_is_synthetic (route via buffers().registry), render.rs::lsp_diagnostics_on_line (route via read_editor), gpui/window.rs::picker_display_is_minibuffer + paint_pane + picker_substate, gpui/lib.rs::Effect::Global (build-outcome-in-closure pattern). Bench lattice-ui-tui::benches::render routed through the seam. lattice-host (257) + lattice-ui-tui (1392) + lattice-ui-gpui (23) tests green. What this means architecturally: paramount goal #4 ("Asynchronicity: nothing blocks the UI — enforced architecturally, not by discipline") is now compile-time enforced for production. The renderer thread cannot construct a &mut Editor reference; every mutation goes through the actor mailbox and runs on the dedicated editor thread. Reads either go through wait-free RS sub-state accessors (ad(), panes(), buffers(), popup(), etc.) or through read_editor's blocking RPC. Outstanding follow-up (perf): the per-frame read_editor closures in render.rs / runtime.rs / window.rs each cost a sync mailbox round-trip (~µs). 12-15 reads per frame × ~µs = well under the 8.3 ms one-frame ceiling at 120Hz, but the audit-planned hot-path RS lifts (MessagesRenderState.last, ModelineRenderState.{auto_submit_hint,search_pattern,search_direction}, new fields for pane_highlights / buffer_locals / active_modes / config) should follow as a B-extension slice to drop those round-trips to wait-free Arc clones.
3c.final.E.5jRenderer crates drained to ZERO — full TUI + GPUI peer migration. Final sweep across crates/lattice-ui-tui/src/{render,runtime,app/boot,app/help,app/lifecycle,app/lsp,app/options}.rs and crates/lattice-ui-gpui/src/{lib,window}.rs. Sites cleared: render.rs (16), runtime.rs (2 — picker/completion popup-row counts + per-frame snapshot), app/boot.rs (2 — boot-time doc-id read + initial publish), app/help.rs (2 — popup_buffer + buffer_locals link lookup), app/lifecycle.rs (1 — document_path via published registry), app/lsp.rs (2 — show-message-request map + buffer_uris+supervisor for code-action handle), app/options.rs (2 — _for_test setters cfg(test)-gated), gpui/lib.rs (1 — boot publish), gpui/window.rs (9 — render_state loads via App's own Arc, viewport_height via ad(), command_line / search_line via read_editor closures, insert_completion via completion().insert, popup substate via render_state). Recipe catalog from E.5g–E.5i applied uniformly: RS-accessor swaps where the data is already published (picker_state(), completion(), popup(), panes(), buffers().registry, render_state.load()); read_editor closures returning owned values for fields not yet on RS (paint_request, buffer_locals.*, pane_highlights, auto_submit_after_chord, search_line, last_message, active_modes, lsp_pending_show_message_requests, command_line); mutate_editor with empty closure for boot-time publishes; #[cfg(test)] gate for the two _for_test config setters. help_render_data signature changed from &[T] to Vec<T> (owned) so the closure can return without leaking borrows; callers already used .cloned() so no caller-side changes needed. Net: TUI + GPUI peer production `(app
3c.final.E.5iTUI app/ tree drained to zero — final 11 production sites migrated by recipe. Audit of the remaining production self.editor.X sites in crates/lattice-ui-tui/src/app/: 11 sites across 6 files (motions.rs 4, edit.rs 2, cmdline.rs 2, options.rs 1, popup.rs 1, app.rs 1). Five distinct recipes applied: A — eager-materialize for impl Trait args: App::set_message's text: impl Into<String> couldn't ride through mutate_editor (closure bound rejects the impl arg) — fixed by materializing text.into(): String outside the closure, then capturing the owned String by move. Improvement over the E.5c reverted attempt that left it on direct field access. B — typed read_editor for generic readers: App::resolved_option<D: OptionDecl> routes through `read_editor(move
3c.final.E.5happ/picker.rs + app/lifecycle.rs drained: cfg-gated-dead-code purge + test-fixture gate. Same audit shape as E.5g: classify each self.editor.X site by real production reachability rather than mechanical grep count. picker.rs (11 raw hits → 0 production): 8 of the 11 grep hits lived inside _retired_do_picker_accept_body, a 248-line function held under #[cfg(any())] (always-false → never compiled) as a "historical reference" after G.11 moved the live do_picker_accept routing host-side. Git history is the canonical reference — carrying cfg-disabled code in-file just inflates the migration counter without ever running. Deleted the function outright (~250 lines incl. its now-unused EchoLevel #[cfg(any())] import + the BufferId import that was only reached from the retired body). Of the 3 surviving production-counted sites: App::preview_picker_selection (L252) had zero callers anywhere — host invokes Editor::preview_picker_selection directly from Editor::dispatch and routes its Vec<RendererSignal> through the outcome surface; deleted as dead. App::build_picker_context (L48) had only test-fixture callers (this file's mod tests + the entire picker_sources.rs module, which is itself documented as TUI test harness re-exporting from lattice_picker::picker_sources) and host-internal call sites that route through Editor::build_picker_context; moved to #[cfg(test)] impl App. App::bump_live_picker_debounce (L186) was already #[allow(dead_code)] with two test callsites in this file's mod tests — moved to the same gated block. lifecycle.rs (3 production → 0): document_folds_for, document_last_parsed_text_version_for, and document_last_synced_syntax_version_for were all already #[allow(dead_code)] with only a single test caller each (the document_locals_* test). Production path uses the host-side copies directly from pane_highlights.rs. Moved all three into a #[cfg(test)] impl App block. Net: picker.rs production sites 11 → 0; lifecycle.rs production sites 3 → 0; picker.rs file size dropped by ~250 lines (the cfg(any()) body); cumulative TUI app/ production sites: 60 (post-E.5f) → ~47 estimated (need a re-count). lattice-host (257) + lattice-ui-tui (1392) + lattice-ui-gpui (23) tests green. Pattern reaffirmed: cfg(any()) "preserved-for-reference" dead code costs more than it's worth — every audit pass treats those hits as real, and the historical reference is one git log away. Delete on sight.
3c.final.E.5gapp/completion.rs drained: dead-delegate sweep + #[cfg(test)] gate. Audit of completion.rs's 8 remaining self.editor.X sites surfaced that none serve production callers — 4 delegates are entirely dead (zero callers anywhere: populate_insert_completion_sync, maybe_refresh_insert_completion_after_edit, expand_snippet_with_lsp_edits, expand_snippet; host invokes its own copies directly inside Editor::dispatch) and the other 4 are pure test-fixture surface (refilter_insert_completion, do_completion_accept_then_insert, snippet_meta_for, effective_completion_for; their only callers are inside #[cfg(test)] mod tests blocks in app.rs, app/dispatch.rs, app/options.rs, and completion.rs itself). Per the audit (3c-final-audit.md §7) and CLAUDE.md heuristic #1 (best long-term fit beats easy implementation), shaping host signatures around closure Send + 'static bounds — clone-the-ref-arg, owned-DispatchOutcome return, pure-function refilter — would propagate the renderer-thread contract uniformly through methods that no production renderer-thread caller ever uses. Instead: deleted the 4 dead delegates outright (~50 lines incl. stale doc comments claiming "retained for App-side callers" that were migrated host-side in prior slices) and moved the 4 test-only delegates into a dedicated #[cfg(test)] impl App block. Production cfg(not(test)) build no longer compiles those methods at all; self.editor.X site count for completion.rs production code: 8 → 0. The 4 gated delegates retain their self.editor.X bodies behind the cfg-gate and carry a comment that they'll flip to the audit's planned App::editor() / App::editor_mut() cfg-gated accessors when the actor-swap lands. Imports for Position, SnippetCandidateMeta, EffectiveCompletionConfig gated #[cfg(test)]; Buffer import dropped (only doc-comment references remained). Module docstring updated to reflect the migrated host-side authority over populate_* / refilter_* / accept-paths / expand_snippet. lattice-host (257) + lattice-ui-tui (1392) + lattice-ui-gpui (23) tests green. Pattern: when a renderer-thread self.editor.X site turns out to be reachable only from tests, the slice-F escape-hatch (#[cfg(test)] impl App) is the design-philosophy-correct landing — not a host-signature refactor that adds allocation cost to production paths to satisfy a constraint that doesn't apply to them.
3c.final.E.5eMethodify-on-host: sync_keymap_overlays hoisted. The keymap-layer-stack reconciliation body (~40 lines, 14 direct self.editor.X sites in app/boot.rs) moves to a single new Editor::sync_keymap_overlays in lattice_host::dispatch, with the prior App::sync_completion_popup_mode_activation (8 sites, sole caller) inlined into it. Renderer-side App::sync_keymap_overlays collapses to a 1-line mutate_editor delegate. Net: app/boot.rs drops to 0 production self.editor.X sites (was 29 at the start of slice E; was 14 before this swap). Cumulative TUI app/ + app.rs: 83 → 60 (23-site drop in one methodify). The hoist also clarifies ownership — the popup-mode activation logic was never renderer-coupled; it was pure editor state machinery that happened to live in the App. lattice-host (257) + lattice-ui-tui (1392) + lattice-ui-gpui (23) tests green. Demonstrates the pattern for the remaining methodify-on-host candidates: the ExCommandRegistry-bound cmdline.rs helpers (do_command_line_describe_under_cursor, try_resolve_missing_arg_prompt), the picker.rs accept-routing arms, the lifecycle.rs version readers. Each is a 1-method-per-slice hoist where the body's self.editor.X count converts to zero in exchange for one &mut Editor method on host.
3c.final.E.5dRule-of-three App helpers + RS-accessor sweep. Methodified the host's stale App::push_recent_file 4-step body to a single mutate_editor call routing into the existing Editor::push_recent_file (-4 sites in one delete). Added 3 rule-of-three App-side helpers earning their keep by call-site frequency: App::cursor() / set_cursor() (8+ readers across dispatch/help/search/lsp/Debug); App::document_buffer_id() (5+ readers across lsp/boot/lifecycle); App::command_line() (5 readers across cmdline/search/Debug). For fields already mirrored on RenderState by prior B-slices, reused existing accessors instead of adding helpers: pane_tree.Xpanes().tree.X (4 sites in help/lifecycle), buffers.Xbuffers().registry.X (4 sites in lifecycle/dispatch), option_cache.Xad().option_cache.X (7 sites in options), active_bufferad().buffer_kind (4 sites in dispatch), document.snapshot()ad().snapshot.clone() (5 sites in picker/search/help/Debug + 7 in picker_sources), popup_bufferpopup().buffer_id (5 sites in help/popup), modalad().modal (3 sites in cmdline + Debug), should_quitrender_state.load().lifecycle.should_quit (2 sites in dispatch + GPUI peer), host_themerender_state.load().theme (2 sites in boot/dispatch). App::buffer_uri signature change: Option<&Uri>Option<Uri> (Uri is internally Arc<str>-backed, clone is cheap, returning a borrow through the temp Arc<BuffersRenderState> is E0515 anyway). Two test updates required: chord_capture_active's modal read reverted to self.editor.modal (test mutates editor.modal directly without publishing RS — same precedent as motions.rs pane_tree); the lsp_close_buffer_removes_uri_mapping_for_unattached_buffer test gains explicit app.editor.publish_render_state() after direct field mutations (the swap-safe pattern). picker_sources.rs migrated app.editor.document.snapshot() (12 sites including test fixtures) to app.ad().snapshot.clone(). render.rs migrated app.editor.render_state.load[_full]() (2 sites) to app.render_state.load[_full]() (App owns its own ArcSwap), app.editor.active_bufferapp.ad().buffer_kind (6 sites), app.editor.command_lineapp.command_line() (1 site), test fixtures' &app.editor.document.snapshot()&app.ad().snapshot.clone() (5 sites). Dispatch core in App::apply: self.editor.dispatch(action.clone())self.mutate_editor_with(move |e| e.dispatch(action_for_dispatch)) with pre-cloned action so the outer match action {} at line 203 still has access. Mid-stream regex bug surfaced + fixed: GNU sed -E treats | as alternation, not literal pipe — corrected with `[
3c.final.E.5cast-grep automated batch + repair pass. Used ast-grep run --pattern 'self.editor.$METHOD($$$ARGS)' --rewrite 'self.read_editor(move |e| e.$METHOD($$$ARGS))' --update-all across crates/lattice-ui-tui/src/ and crates/lattice-ui-gpui/src/. 72 rewrites applied in one shot; 57 cargo-check errors followed. Triage: (a) 29 E0596 cannot borrow *e as mutable — host methods need &mut self; bulk-sed-fixed read_editormutate_editor_with on the 29 specific lines (no-arg fix since mutate_editor_with accepts unit return too). (b) 17 E0521 + 5 E0382 borrowed-arg sites (&state, &buffer, &mut out, &str, &Path, &body) — Send + 'static closure bounds force these to fail; reverted to direct self.editor.X(...) access via second sed pass using regex `self.read_editor(move [
3c.final.E.swapattempted + revertedActor-swap landing attempt — reverted to keep workspace green; lessons captured for the focused follow-up. Added two new actor mailbox variants (Read, MutateWithReply) carrying type-erased Box<dyn Any + Send> payloads so &self reads and &mut self mutations with non-unit return types can both go through the actor. EditorActorHandle gained with_editor<R>(f) -> R and mutate_blocking_with<R>(f) -> R typed wrappers. App::read_editor<F,R>(f) -> R helper added on the renderer side, parallel to the existing mutate_editor / mutate_editor_with. Attempted the literal struct swap (replace pub editor: Editor with pub editor_actor: EditorActorHandle, cfg-gated for cfg(any(test, feature = "test-internals"))) — produced 308 compile errors across 18 files (49 in render.rs, 34 in lsp.rs, 31 in lifecycle.rs, 29 in boot.rs, 23 in help.rs, …). Far higher than the ~80 production-site estimate because many &self reads that the prior E sweeps deferred turned out to live in production paths (not cfg(test) modules), and render.rs has its own production-path access patterns I hadn't counted (49 sites). The cfg-gate of the field alone — which only protects cfg(test) mod tests blocks — doesn't help these non-test production functions. Reverted the struct swap; kept the new actor mailbox + handle methods (Read, MutateWithReply, with_editor, mutate_blocking_with) since they're additive infrastructure ready for the focused follow-up. Workspace check + lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green after the revert. The lesson for the follow-up: migrate the remaining ~308 production-side self.editor.X sites to read_editor / mutate_editor / mutate_editor_with FIRST (one cluster per focused session), THEN do the struct swap once the count is at zero. The swap becomes trivial when every site already routes through helpers — only the helper bodies + App::new + the cfg-gated test accessor change.
3c.final.E.5Heavy-batch sweep through lsp.rs + gpui/lib.rs. app/lsp.rs migrated from 94 → 30 sites: 27 simple void delegates (maybe_request_*, drain_*, do_*_request family) routed through mutate_editor; 9 signal-returning ones (drain_pending_hover, drain_inbound_show_documents, drain_inbound_apply_edits, drain_pending_rename, drain_pending_code_actions, drain_pending_signature_help, drain_pending_definitions, do_lsp_status, do_lsp_follow_link_at_cursor, etc.) via mutate_editor_with; 12 with &str/Option<&str> args (do_lsp_restart, do_lsp_log_clear, do_set_lsp_log_level, do_open_lsp_log, do_open_lsp_trace_log, do_toggle_lsp_trace, do_lsp_progress_cancel, open_lsp_log_in_pane, open_lsp_trace_log_in_pane, do_lsp_rename_request, fan_out_did_change_configuration, jump_to_lsp_location) using the clone-to-owned-then-as_deref pattern; 11 with non-&str args (apply_lsp_completion_accept, lsp_close_buffer, execute_lsp_command, open_show_message_request_picker, apply_lsp_code_action, apply_rename_workspace_edit, apply_lsp_text_edits, do_lsp_format_request(bool), do_lsp_call_hierarchy_request(bool), do_lsp_type_hierarchy_request(bool), accept_lsp_code_lens(usize), accept_lsp_color_presentation(usize)). gpui/lib.rs migrated from 64 → 6 sites: most Effect::Lsp* and Effect::* match arms routed through mutate_editor / mutate_editor_with; the TranslateContext reads at dispatch_keystroke swapped to rs.translator (same pattern as TUI runtime.rs); rebuild_gpui_theme reads host_theme from rs.theme; the boot-time viewport_height seed + activate_major_for_buffer_kind chain bundled via mutate_editor / mutate_editor_with. Final remaining ~80 sites are concentrated in: (a) &self boolean reads in lsp.rs *_enabled_for(buffer_id) family — need read-side actor API for the swap; (b) the dispatch cores editor.dispatch(action) in App::apply and GpuiApp::dispatch_action — the actor swap target itself; (c) keymap-layer multi-step bodies in boot.rs::sync_keymap_overlays + sync_completion_popup_mode_activation — need refactor before bundling into a closure; (d) do_global / do_insert_text &mut out patterns that interleave host calls with App-side effect application; (e) app.rs::Debug impl field reads. Cumulative through E.0–E.5: ~250 of ~500 sites migrated; pattern proven across 18 files; lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green at every checkpoint. The remaining sites are the actor-swap target: they need the swap to land (App.editor → App.editor_actor) which compile-breaks them all simultaneously, then refactors each cluster to the post-swap shape (read-side API / accessor methods / typed Action variants).
3c.final.E.4Boot + LSP + GPUI peer clusters partially migrated. app/boot.rs (2 muts: sync_host_theme_from_config, load_persistent_config; the complex sync_completion_keymap_layers multi-step body and remaining boot-time field reads stay for the final swap). app/lsp.rs (9 muts mechanically migrated via replace_all: do_completion_resolve_focused, do_lsp_insert_completion_request, drain_pending_completion_resolve, drain_pending_insert_completion_lsp, drain_lsp_log_events, drain_lsp_detach_events, drain_lsp_progress_events, drain_inbound_configuration_requests, publish_document_opened_for_active; the remaining 94 sites are mostly X_enabled_for(buffer_id) boolean reads with &self receivers + complex multi-step tokio task callbacks). lattice-ui-gpui/src/lib.rs gains parallel GpuiApp::mutate_editor + mutate_editor_with helpers (same shape as the TUI peer); 4 GPUI muts migrated (rebuild_option_cache, publish_document_opened_for_active, ensure_subsystem_buffers, apply_per_language_toml_overrides). lattice-ui-tui/src/runtime.rs swaps 3 app.editor.render_state.load() reads to app.render_state.load() (decouples from the editor field). Cumulative through E.4: ~110 of the audit's ~500 sites migrated; routing pattern proven across 16 files; tests stay green throughout. What's left for the actor swap itself (E.swap, deferred to a follow-up): ~390 self.editor.X sites mostly in lsp.rs boolean-read helpers, the dispatch core (App::apply's editor.dispatch(action) line), the keymap-layer manipulation in boot.rs, GPUI peer's dispatch core + LSP effect arms, and the Action::Mutate-incompatible &self accessors. The swap itself is a localized change to App::mutate_editor / App::mutate_editor_with bodies (route to editor_actor.mutate_blocking instead of in-process), plus replacing App.editor: Editor with App.editor_actor: EditorActorHandle in App::new — but the type-system enforcement of paramount goal #4 only lands once the remaining ~390 sites also migrate (each one becomes a compile error after the swap until it's rewritten). lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.E.2Routing helpers + 6 small/medium method-call clusters migrated. Adds two App methods that route editor mutations through a single, swap-localizable seam: App::mutate_editor(F) for void-return closures and App::mutate_editor_with<F,R>(F) -> R for closures returning a Send + 'static value (typically Vec<RendererSignal> or Result<_, RuntimeError>). Both currently call f(&mut self.editor) + publish_render_state(); post-swap their bodies become self.editor_actor.mutate_blocking(Box::new(f)) / .mutate_blocking_with(f) — one-line change per helper. Forward-compatible Send + 'static bounds applied today so callers don't need to retouch on swap. Six clusters migrated via these helpers: app/oil.rs (4 muts; 2 &self reads deferred), app/folds.rs (3 muts; 3 &self reads deferred), app/mode.rs (6 muts), app/file_tree.rs (6 muts; 4 &self getters deferred), app/edit.rs (5 simple muts: apply_edit_blocking, apply_edit_batch_blocking, undo_blocking, redo_blocking, do_paste; 2 complex muts do_global/do_insert_text that take &mut out: DispatchOutcome deferred for a careful refactor), app/motions.rs (5 muts: clamp_cursor_to_buffer, clamp_cursor_to_active_buffer, ensure_cursor_visible, do_jump_mark, push_position_history; the 3 pane_tree + popup_placement reads tried via app.panes() / app.popup() were reverted after 2 test failures — tests mutate editor.pane_tree directly without republishing, so the RS-backed reader returned stale geometry; a follow-up adds explicit publishes to those tests then re-migrates). The &self read-only delegates need a different routing (actor-side read API) and are queued for the final swap. Total clusters complete after E.1 + E.2: 7 (highlights + oil + folds + mode + file_tree + edit + motions); ~35 mutating sites migrated cluster-by-cluster. Pattern proven, scaled: further clusters are mechanical applications of mutate_editor / mutate_editor_with. Lesson learned: read-side migrations from self.editor.X to self.X() (RS-backed accessor) require publish discipline tests don't always have today — those migrations need to land with explicit test publishes. Remaining sweep: app.rs (16), cmdline.rs (16), popup.rs (18), options.rs (19), completion.rs (23), help.rs (25), dispatch.rs (30), boot.rs (31), picker.rs (32), lifecycle.rs (43), gpui/lib.rs (66), lsp.rs (103), plus search.rs (complex interleaving), motions.rs reads (3), oil.rs/folds.rs/file_tree.rs &self getters (9). Workspace check + lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.E.1Proof-of-pattern: app/highlights.rs cluster fully migrated. Adds App::syntax_visible_spans_cell: Arc<ArcSwap<VisibleSpans>> mirroring the existing App::render_state cache from 3c.atomic.A. Initialized in App::new (boot.rs) as a clone of editor.syntax_visible_spans_cell — pre-swap this is a direct clone, post-swap the same Arc comes from the actor handle. Three reader sites in app/highlights.rs migrate from self.editor.X to App-cached fields: refresh_highlights reads &self.render_state + &self.syntax_visible_spans_cell for highlights_worker::recompute, highlights_for_buffer_line reads self.render_state.load_full(), visible_buffer_line_extent reads self.ad().snapshot.buffer.line_count(). One residual self.editor.publish_render_state() call in refresh_highlights remains — flagged as the "final swap" tail (it becomes self.editor_actor.apply_blocking(Action::None) when Editor moves to the actor). Pattern established for the per-cluster sweep: each cluster identifies the Arcs/snapshots it needs from Editor, caches them as App fields at construction, migrates reads through those fields. The constructor (App::new and the GPUI peer's equivalent) is the one place that changes when the actor swap finally lands. lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.
3c.final.E.0Actor mailbox extended for the slice-E sweep — infrastructure ready, swap deferred. EditorCommand gains three new variants: ApplyAndReply { action, reply: oneshot::Sender<()> } (sync dispatch barrier), Mutate(Box<dyn FnOnce(&mut Editor) + Send>) (fire-and-forget closure escape hatch), and MutateAndReply { closure, reply } (sync variant). The actor's run_actor loop handles each; Mutate and MutateAndReply publish RS unconditionally after the closure runs so callers' next read sees the mutation. EditorActorHandle gains apply_blocking(action), mutate_blocking(closure), mutate_async(closure) returning Result<(), ActorGone>. New ActorGone error type for the "editor thread died" fatal condition. Three new tests lock the contracts: apply_blocking publishes a fresh RS before returning, mutate_blocking applies the closure and publishes, mutate_async lands after a subsequent apply_blocking barrier. Scope reality: the actual App.editor: Editor → App.editor_actor: EditorActorHandle swap touches ~500 self.editor.X sites across 14 files. Each cluster (LSP response handlers in app/lsp.rs, picker setup in app/picker.rs, lifecycle paths in app/lifecycle.rs, GPUI App helpers in lattice-ui-gpui/src/lib.rs, etc.) needs its own migration strategy (typed Action variant vs closure-wrap vs Arc-handle exposure). That sweep is deferred to slices E.1–E.N as a per-cluster effort, each landing green independently. The closure escape hatch will be retired cluster-by-cluster as typed variants land — the discipline tax exists but is bounded and tracked. lattice-host (257) + lattice-ui-gpui (23) + lattice-ui-tui (1392) tests green.

Cumulative test sweep (after 3c.atomic.F): 228 host + 1410 ui-tui + 21 ui-gpui green. The two original primitives (render_state + per_buffer_cache) gained the editor-actor module's 10 tests covering ping/pong, apply→publish, drop-joins, idempotent shutdown, silent tick, and each typed setter command publishing a fresh Arc<RenderState>.

Pattern observed across 3c.atomic. reader migrations:* every migration uncovered at least one out-of-dispatch write site that didn't publish — set_viewport_height in D, open_popup and install_help in E. These were latent bugs masked by the renderer reading directly from editor.X; migrating to ad() forced each writer to commit visibly to the actor contract. This is the architectural enforcement of paramount goal #4 — by construction the renderer cannot observe writes that haven't been published.

Status of the post-:e UI hang: Resolved by Slices 1 + 2. The flagship reproduction (:e crates/lattice-lsp/src/lib.rs against this workspace's populated target/) no longer freezes; the watcher install completes in 384 ms (off the renderer thread) and ignored target/ events never enter the channel. Diagnostic markers (tracing::info! traces around the watcher install + fs-event drain) remain in place for future regression detection.


GPUI cell-grid renderer (2026-05-26)

Anchor: ../architecture/cell-grid-renderer.md + design.md §5.6 + paramount goal #1.

The held-j stutter reported on 2026-05-25 (cursor advances a few rows then visibly freezes until key release) traced to EditorElement::prepaint's per-frame WindowTextSystem::shape_line calls. The probe (commit 734486d) confirmed the cost is structural to shape_line itself, not call count: a renderer-side content cache (Layer A, retired) reduced calls from 216 to 2 per paint but each remaining call cost the same ~10 ms, conserving total wall time at ~20-30 ms/paint during scroll.

A renderer that ships shape_line on the code-buffer hot path cannot hit goal #1's one-frame keystroke→glyph ceiling (≤ 8.3 ms at 120 Hz). The cell-grid + glyph atlas decomposition (alacritty / wezterm / VSCode / browser model) replaces it for code/terminal/file-tree/synthetic buffers; the Shaped path stays for help/markdown/popup surfaces where rich text matters.

SliceStatusWhat lands
S1 cell substrate✅ landedNew lattice-cells crate: Cell (16-byte), CellRow, CellChunk, CellMatrix, MatrixVersion. Pure data, slicing API, 31 unit tests for whole-doc / multi-chunk / fold-elision / out-of-bounds.
S2 cell-builder worker✅ landedTokio worker in lattice-host, sibling of highlights_worker. Subscribes to (text, syntax, inlay, fold, theme) version cascade via MatrixVersion; coalesces via Notify permits; rebuilds intersecting chunks (S2.4.b); publishes via ArcSwap<CellMatrix> on RenderState. Edits past the change point shift start_source_line without rebuilding cell content. All sub-slices landed (S2.1–S2.5); S3 (TUI cutover) and S4 (GPU paint_cells) can now consume the matrix.
S2.1 plumbing✅ landedCellsRenderState in render_state.rs (matrix Arc<ArcSwap<CellMatrix>>, wake Notify, MatrixVersion axes, snapshot/syntax/inlay/folds/theme inputs). Editor.cells_matrix_cell + Editor.cells_wake. publish_render_state populates the cells state. Workspace tests green.
S2.2 minimal worker✅ landedcells_worker.rs sibling of highlights_worker.rs. Wake → read RS → build whole-doc CellMatrix from rope text alone (ASCII codepoints, no syntax fg, no inlays, no folds). Spawned from editor_boot.rs with stable-Arc identity over cells_matrix_cell + cells_wake. WorkerDecision covers Clear / CacheHit / Recomputed; the published matrix carries the publisher's MatrixVersion so subsequent wakes short-circuit via differs_from. 5 unit tests (clear / cache-hit / recompute / version-bump / empty-text).
S2.3 full cell content✅ landedSyntax span → cell.fg, inlay-hint splicing, fold elision, theme palette wiring. All three sub-slices (S2.3.a / S2.3.b / S2.3.c) landed; matrix is feature-complete content-wise. S3 (TUI cutover) can now start in parallel with S2.4–S2.5 perf work.
S2.3.a syntax fg + theme✅ landedHash derived on host Theme / Style / Color / NamedColor / Modifiers. CellsRenderState gains theme: Theme. dispatch.rs folds hash(host_theme) into MatrixVersion::theme. Worker calls snapshot.highlight_lines(...) when the syntax snapshot is current, walks per-line spans, resolves Style → fg via theme.syntax_style. Stale syntax (snapshot text_version < doc text_version) falls back to default fg. 4 tests cover keyword/comment fg, no-handle default-fg, stale-syntax fallback, theme-version-bump rebuild.
S2.3.b inlay splicing✅ landeddispatch.rs clones the gated inlay-hint Arc into the cells substate (single cache scan, two readers). Worker buckets by line, splices each (orig_byte, text) during the char walk: emits one INLAY-flagged Cell per inlay char (hard-coded DarkGray fg), records (orig_byte, char_width) on CellRow::inlay_offsets. Trailing inlays (orig_byte == line_len) splice at EOL; multiple inlays per line sort ascending by byte. Inlay theme slot is follow-up alongside #19. 5 tests cover mid-line splice + flags + offsets, byte ordering, byte-0 leading splice, trailing-EOL splice, inlay-version-bump rebuild.
S2.3.c fold elision✅ landedCellsRenderState gains foldenable: bool; dispatch folds it into MatrixVersion::folds for the cells axis (the syntax substate keeps its list-only hash since it doesn't elide). Worker builds FoldIndex::from_folds(&cells.folds, cells.foldenable) and skips lines where line_inside_closed_fold(line) == true. The fold's start_line stays visible (vim semantics — fold marker renders there); interior lines (start_line, end_line] are elided. CellMatrix::source_line_count preserves the pre-fold count; visible_line_count reflects post-fold rows. 4 tests cover closed-fold interior elision, open-fold no-op, foldenable-off disables elision, two non-overlapping closed folds.
S2.4 chunked mode✅ landedChunked mode + incremental rebuild. S2.4.a landed the structural switch; S2.4.b landed the incremental rebuild with prefix-reuse / rebuild zone / suffix-shift partitioning.
S2.4.a chunked-mode switch✅ landedpick_chunk_size(viewport_height, line_count) returns WholeDoc when viewport_height == 0 or line_count ≤ 4 × viewport_height, otherwise Chunked(next_pow2(2 × viewport_height)) (clamped to a 16-line floor). build_matrix builds a ChunkInputs<'_> borrow bundle once and build_chunk_rows(&inputs, start, end) produces each chunk's rows. 6 tests.
S2.4.b incremental rebuild✅ landedNew lattice_cells::EditDelta { start_line, lines_removed, lines_added } + CellRow::with_source_line + CellChunk::shifted_by (cell Arc payload shared via refcount-only clone). Editor.last_edit_for_cells tracks the per-publish-cycle single-edit delta in publish_document_changed (cleared on batch / second-edit / undo / redo); build_render_state take()s it. CellsRenderState.last_edit carries it to the worker. try_incremental_build checks eligibility (single-text-edit step, other axes unchanged, mode + chunk_size compatible, line-count consistency); on success the chunked path partitions into prefix-reuse (Arc::clone), rebuild zone (build_chunk_rows over [rebuild_lo, rebuild_hi)), and suffix-shift (shifted_by(net_delta)). Whole-doc rebuilds the only chunk. New WorkerDecision::RecomputedIncremental variant. build_render_state / publish_render_state / set_selections_blocking changed from &self to &mut self; renderer-side `read_editor(
S2.5 coalescing✅ landedtokio Notify permit-style coalescing verified end-to-end. A burst of N wakes during one build produces exactly 2 builds (original + tail catch-up against the latest render_state.load_full()); no explicit debounce needed. paint_request fires on Recomputed / RecomputedIncremental / Clear; CacheHit stays silent. Cells + highlights workers write to independent ArcSwap cells with no contention. Worker docs now explicit on the coalescing contract. 4 tests cover decision state machine under interleaved wakes, no-cross-talk between cells and spans cells, full end-to-end through the tokio run loop, and burst-coalescing via Notify permits.
S3 TUI cutover✅ landedTUI document-body rendering now exclusively consumes Arc<CellMatrix>. Sub-slices: S3.a (substrate modifier bits), S3.b (TUI converter), S3.c.0 (body wiring), S3.c.1–4 (per-overlay validation: whitespace / semantic tokens / bg-layer overlays / fold suffix + inlay splice), S3.c.final (retire RowPrepaint fallback). Highlights worker continues to publish for markdown / help / messages bodies.
S3.a cell modifier bits✅ landedCell::flags gains BOLD / ITALIC / UNDERLINE / DIM / REVERSE constants + accessors. cells_worker::resolve_style(theme, style) -> (fg, flags) replaces resolve_fg; the worker packs theme.syntax_style(style).modifiers into cell flags via modifiers_to_flags. ChunkInputs.default_flags propagates the theme's default modifiers. Inlay cells stay isolated from syntax modifiers. 5 tests.
S3.b TUI body consumer✅ landedTUI converter module lattice_ui_tui::cells_render: cell_row_to_combined_spans (post-inlay columns) + cell_row_to_source_spans (INLAY-filtered; source-byte compatible with the legacy body-spans shape). Walks cells once, groups consecutive cells by (fg, bg, modifier_bits), emits one ratatui::text::Span per group. cell_to_style maps cell.fg/bg != 0 to Color::Rgb and leaves 0 unset (host Color::Default semantics); modifier flags map to Modifier::BOLD/ITALIC/UNDERLINED/DIM/REVERSED. 13 tests cover the empty case, grouping, modifier table + composition, INLAY filtering, all-INLAY empty, non-ASCII, and a realistic keyword/identifier/paren row. Wiring into draw_buffer deferred to S3.c.
S3.c TUI cutover🔄 in progressSliced one overlay at a time. S3.c.0 = body wiring; S3.c.1–4 = per-overlay validation; S3.c.final = retire RowPrepaint fallback.
S3.c.0 body wiring✅ landedCellMatrix::row_at_source_line(target) -> Option<&CellRow> (linear chunk walk, sub-µs at typical scale). compose_visible_lines_inner's non-messages body branch prefers cell-derived spans via cell_row_to_source_spans(row) + truncate_spans_to_width; falls back to RowPrepaint when the matrix has no row (boot frames / folded rows / out-of-coverage). Fallback is bit-identical to the pre-cutover shape so matrix-lag windows don't flicker. Downstream overlays consume the body opaquely and continue to work — 1420+ TUI tests still pass. Workspace 3157 / 3157 (was 3154, +3 substrate lookup tests).
S3.c.1 whitespace overlay✅ landed7 focused tests in cells_render::tests feed cell-derived source spans through render::apply_whitespace_decoration and assert glyph substitution lands at the correct source-byte positions: mid-line space, leading whitespace, trailing whitespace, tab, EOL marker append, all-off noop, multi-fg span boundary. Confirms the classifier walks bytes opaquely regardless of how spans were split. Workspace 3164 / 3164 (was 3157, +7).
S3.c.2 semantic tokens✅ landedapply_semantic_token_overlay visibility bumped to pub(crate) for test access (matches apply_whitespace_decoration precedent). 6 focused tests validate the overlay walks cell-derived spans correctly: partial coverage splits into pre/mid/post; full coverage replaces fg across the row; out-of-range no-op; existing BOLD modifier preserved while overlay adds ITALIC; fg replaced + bg from earlier pass preserved; multi-span body with overlay straddling a fg boundary still fires the overlap region's fg-replace. Workspace 3170 / 3170 (was 3164, +6).
S3.c.3 bg-layer overlays✅ landedapply_match_overlay (visual / hlsearch / current_match / substitute / doc-highlight — REPLACE-style) and apply_underline_overlay (diagnostics — ADDITIVE) visibility bumped to pub(crate). 8 focused tests pin the contracts: match overlay splits at partial coverage / covers full row / no-ops out-of-range / walks across multi-span boundaries / sequential overlays REPLACE rather than merge (cell's BOLD dropped when overlay style omits it) / composes by sequence (later overlay overrides earlier bg); underline overlay ADDs UNDERLINED while preserving fg, bg, and existing BOLD on the overlap region. Workspace 3178 / 3178 (was 3170, +8).
S3.c.4 fold + inlay splice✅ landedsplice_virtual_text_into_spans visibility bumped to pub(crate). 7 focused tests pin the contract that the post-overlay inlay splice and the closed-fold suffix both compose with cell-derived bodies: byte-0 prepend / mid-span split / span-boundary clean insert (no split) / past-end append / multi-inlay reverse-byte-order sequencing / fold suffix tail append / fold-suffix-after-inlay-splice ordering. Workspace 3185 / 3185 (was 3178, +7).
S3.c.final retire RowPrepaint✅ landedcompose_visible_lines_inner's document-body branch retired the RowPrepaint fallback. The cell-derived path is the sole source for document bodies; empty-matrix windows (boot frame before the cell-builder's first publish, or buffer-switch gap) emit Span::raw(line_text) — semantically equivalent to the legacy fallback's degraded state. Highlights worker continues to run; view.visible_rows stays published for markdown / help / messages bodies in other render functions. Workspace 3185 / 3185 — no regression. S3 closes.
S4 GPUI cutover🔄 in progressSliced like S3.c. S4.0 converter landed; S4.1 wires it into EditorElement::prepaint with a 3-way fallback (cells → prepaint → legacy); S4.2 propagates modifier bits into TextRun font/flags; S4.3 retires shape_row_from_prepaint for active-pane document bodies; S4.final replaces shape_line with a glyph-atlas paint_cells. Status line / popup / picker / help stay on the Shaped path throughout.
S4.0 cell→TextRun converter✅ landedlattice-ui-gpui::cells_paint module (gated behind window). cell_row_to_text_runs(row, font) -> (combined_text, Vec<TextRun>, inlay_offsets) produces the exact triple EditorElement::prepaint feeds into WindowTextSystem::shape_line. Walks row.cells once, groups consecutive same-fg cells into one TextRun (mirrors build_line_with_inlays's collapse), passes row.inlay_offsets through verbatim. make_run_with_color visibility bumped to pub(crate). S4.0 keeps fg-only modifier coverage to match legacy visual parity; BOLD / ITALIC / UNDERLINE / DIM / REVERSE propagation deferred to S4.2. 8 tests cover empty / single / adjacent-merge / fg-break / inlay-offsets passthrough / inlay-cells-separate-run / realistic keyword+ident+paren row / non-ASCII utf-8 byte length.
S4.1 body wiring✅ landedEditorElement gained cell_matrix: Option<Arc<CellMatrix>> populated from render_state.cells.matrix.load_full() at the window.rs construction site (active pane only — inactive panes pass None, mirroring the visible_rows split). prepaint's body added shape_row_from_cells(row, window) closure that runs cell_row_to_text_runsshape_line. Both dispatch sites (no-gutter fallback walk + gutter-driven walk) now use a 3-way cells → prepaint → legacy if-let chain: cells looked up by absolute source line via CellMatrix::row_at_source_line, falling through to shape_row_from_prepaint and finally shape_row on None (folded rows / boot frames / buffer-switch gap). 8 cells_paint tests + 363 host + 1461 cells+TUI all green. Mirrors S3.c.0.
S4.2 modifier propagation✅ landedcell_row_to_text_runs regrouping key changed from fg only to (fg, bg, style_bits) where style_bits = flags & (BOLD|ITALIC|UNDERLINE|DIM|REVERSE). Per-cell TextRun construction now reads modifier bits: BOLD → font.weight = FontWeight::BOLD; ITALIC → font.style = FontStyle::Italic; UNDERLINE → underline = Some(UnderlineStyle { thickness: 1px, color: None, wavy: false }); DIM → fg + bg RGB channels multiplied by 0.6; REVERSE → fg ↔ bg swap (with documented bg=0 fallback). cell.bg != 0 also passes through as TextRun.background_color. INLAY and WS_MARKER are deliberately excluded from STYLE_FLAGS_MASK — provenance flags don't break runs. 18 tests (10 grouping + 8 modifier) cover the table and composition cases.
S4.3 retire prepaint path✅ landedshape_row_from_prepaint closure + plumbing retired in EditorElement::prepaint. Both dispatch sites collapse from a 3-way cells → prepaint → legacy chain to a 2-way cells → legacy. EditorElement.visible_rows field, the RowRun import, the active_rows_guard load in window.rs, and the visible_rows: initializer all removed. make_run_with_color stays pub(crate) because build_line_with_inlays's legacy LineRunBuilder is the remaining consumer (retires in S4.final). Highlights worker continues to publish rs.syntax.visible_rows — TUI's markdown / help / messages bodies still consume it. 1461 cells+TUI + 363 host + 41 cells + 18 cells_paint tests all green.
S4.final per-cell paint_glyph🔄 in progressReplaces shape_line on the active-pane document body. GPUI 0.2.2 already exposes Window::paint_glyph with an internal sprite atlas — we don't reimplement the atlas, only the per-codepoint GlyphId resolution + the per-cell paint loop. Sliced into S4.final.a (glyph-id cache), .b (paint_cells path + toggle), .c (hit-testing on cell grid), .d (modifier paint), .e (emoji + font fallback), .f (retire shape_line on the document body). Status line / popup / picker / help / gutter stay on the Shaped path throughout.
S4.final.a glyph-id cache✅ landedGlyphResolver infrastructure in crates/lattice-ui-gpui/src/glyph_resolver.rs. GlyphKey { font_id, ch } keys a HashMap to Option<ResolvedGlyph>. ResolvedGlyph carries font_id (which may differ from key.font_id for fallback-resolved glyphs), glyph_id, and is_emoji (dispatches between paint_glyph and paint_emoji). Some(None) records sticky-unresolvable codepoints so we don't re-run layout for known .notdef cases. 9 cache-only unit tests cover empty / insert / sticky-None / distinct keys / re-insert / emoji flag / fallback FontId / clear. Wiring into GpuiApp + the resolve path (which calls WindowTextSystem::layout_line) lands in S4.final.b.
S4.final.b paint_cells path✅ landedpaint_cells_row(...) walks one CellRow emitting per-cell bg paint_quad + paint_glyph / paint_emoji. GlyphResolver::resolve(ch, font, size, window) added: cache hit returns; miss runs layout_line and extracts the first glyph's (font_id, id, is_emoji). EditorView carries Arc<Mutex<GlyphResolver>> (shared across panes). EditorElement carries the same Arc + prepaint stashes font, font_size, text_ascent for the paint-side paint_glyph calls. Body paint loop branches per row: when paint_cells_enabled() (env var LATTICE_PAINT_CELLS=1) + active pane + matrix covers the source line, paint_cells_row runs and ShapedLine::paint is skipped for that row. Otherwise legacy ShapedLine::paint runs. Cursor + overlays continue to use prepaint ShapedLine metrics until S4.final.c migrates hit-testing. 71 lattice-ui-gpui + 41 cells + 363 host + 1461 cells+TUI all green.
S4.final.c cell-grid hit-test✅ landedSurvey showed ShapedLine::closest_index_for_x was never used and the editor body has no mouse handler today; cursor positioning already uses monospace x/col math. This slice landed infrastructure for the future mouse-select handler. New crate::hit_test module: x_to_combined_col, col_to_x, combined_col_to_byte (handles inlay anchors with cursor-before-byte semantics; clicks inside an inlay snap to its source-byte anchor). 13 tests cover round-trips, defensive clamps, multi-byte chars, single + multi-inlay composition, EOL inlay clamp. No mouse handler wired in — that's a separate UX slice.
S4.final.d modifier paint✅ landedpaint_cells_row now applies all five modifier bits per cell. apply_color_modifiers(cell) runs REVERSE swap (fg↔bg, with bg=0 documented behaviour) then DIM attenuation (each channel ×0.6 via dim_channel). cell_font_variant(base, cell) returns a Font with weight = BOLD / style = Italic per cell — resolved via the existing GlyphResolver cache (different FontIds for variants). cell.is_underline() emits a 1-px paint_quad at baseline + 2px (constant offset; TextSystem::underline_position isn't public in gpui 0.2.2). 12 unit tests cover the helpers. Visual parity with the TUI cells path and the cells_paint TextRun converter.
S4.final.e emoji + fallback✅ landedSurvey confirmed layout_line already does platform-level fallback (returned LineLayout.runs[0].font_id carries the actual rendering font; glyphs[0].is_emoji flags colour glyphs) and paint_cells_row already dispatches paint_emoji vs paint_glyph. This slice added: is_notdef(GlyphId(0)) helper (#[allow(unsafe_code)] for the transmute since GlyphId's field is pub(crate)); sticky-None caching for the three unresolvable cases (empty runs, empty glyphs, .notdef glyph); three tracing::debug! lines (fallback selection, notdef, empty) for RUST_LOG=lattice_gpui::glyph_resolver=debug observability. 1 new test (is_notdef_detects_zero_glyph_id); 10 total glyph_resolver tests.
S4.final.f retire toggle✅ landed (scoped)paint_cells_enabled() env-var toggle retired. EditorElement::paint's active-pane body uses paint_cells_row unconditionally when cell_matrix.is_some(). Legacy shape_line path stays for inactive panes + active-pane transient fallback (boot / buffer-switch / folded). Truly deleting shape_row / build_line_with_inlays / LineRunBuilder / make_run_with_color / cells_paint::cell_row_to_text_runs requires inactive panes to also flow through cells — deferred follow-up needing the cells worker to publish for non-active buffers. 97 lattice-ui-gpui tests green.
S5 bench + tuning✅ landed (cells_worker side)crates/lattice-host/benches/cells_worker.rs Criterion bench for recompute across three workloads × three line counts. Numbers in benchmarks.md: cache_hit ~33 ns flat, incremental_build ~103 µs @ 5k lines, full_build ~1.9 ms @ 5k lines — all well within paramount goal #1's one-frame keystroke→glyph ceiling (8.3 ms at 120 Hz). Paint-side benches (paint_cells_row, GlyphResolver::resolve miss path) defer (need live GPUI window).
S6 cleanup✅ landedStripped [held-j-shape] / [held-j-render] / [held-j-timing] / [held-j-paint] probes and their Instant::now() scaffolding from EditorElement::prepaint + paint, window.rs::Render::render + on_key_down, and GpuiApp::dispatch_action. Held-j on macOS is smooth; WSLg stall is platform-side (compositor backpressure), confirmed via cross-platform reproduction test. S5 Criterion bench + the existing perf benches remain the measurement vehicles going forward. Cell-grid plan closes.

Estimated calendar: 6–9 weeks across all slices.

Decoration assignment. Cells carry codepoint + theme-resolved fg + bg + flags. Inlay hints, syntax colours, fold elision are cell-baked. Cursor, selection, hlsearch, current_match, visual_range, doc_highlights, diagnostic underlines, gutter (line_num, fold_marker, severity), substitute-preview are overlays computed at paint time. Any decoration that doesn't change glyph layout is an overlay → appears on the next paint with zero matrix work.

Trade-off accepted. One-frame cursor-cell lag after a typed character: cursor renders at new column immediately (overlay); the cell under the cursor reflects pre-edit content for at most one frame. Invisible during typing; observable only in constructed stress tests. This is the price of going fully async on edits, and it aligns with paramount goal #4 (no blocking on edit/UI thread).

Layer A (retired). The shaped-line cache (crates/lattice-ui-gpui/src/shape_cache.rs) consolidated shape_line calls without reducing per-call cost. Validated empirically; reverted in tree as part of the cell-grid plan kickoff. See cell-grid-renderer.md § Trade-offs for the audit trail.


live-picker (debounced re-run on query change)

:picker grep and any future "engine produces the candidate set" source -- the source's external program (grep, future LSP workspace-symbols, future shell-driven enumeration) re-runs on each debounced keystroke instead of the picker fuzzy-filtering a single inline batch. Anchor: ../architecture/design.md §5.9.7 ("Live sources" subsection).

SliceStatusWhat lands
live-pkr.1Trait + picker primitive seam. PickerSourceSpec.live: bool (default false), PickerSourceGenerator::on_query_changed(&ctx, &query) -> Option<SourceResult<PickerInitResult>> (default None). Picker.live_source_mode + set_live_source_mode(bool); Picker::refilter short-circuits when live_source_mode == true (renders raw 1:1, no fuzzy scoring, no MRU). App::seat_picker_from_pairs reads entry.spec.live and flips the picker into live mode. No production source opts in yet; all existing pickers bit-identical. Tests: live_source_mode_bypasses_fuzzy_refilter, live_source_mode_off_keeps_existing_fuzzy_behaviour.
live-pkr.2App-side debounce + cancellation. LivePickerQueryState + InFlightLiveQuery on App, installed by open_picker when entry.spec.live is true. LIVE_PICKER_DEBOUNCE = 150ms constant. New methods: bump_live_picker_debounce (called from PickerAppend / PickerBackspace dispatch), drain_pending_live_picker_query (main-loop tick — fires on_query_changed on deadline expiry, pumps in-flight rx, stale-result detection via launched_for_query vs current picker.query). do_picker_dismiss cancels any in-flight task. Tested with synthetic LiveStubSource (registry sidestep — Arc-shared, so hand-installed live_picker_query state). Tests: keystroke→debounce→fire, burst-coalesce, dismiss-clears-state.
live-pkr.3GrepSource flips to live + optional initial pattern. spec.live = true. pattern arg now ArgDefault::None — no-arg opens empty, arg seeds the prompt. init() returns Inline(empty) on no-arg, Future(spawn_blocking(run_grep)) on initial-pattern (first grep no longer blocks UI). on_query_changed() returns Inline(empty) for empty/whitespace, Future for real queries. spawn_blocking because run_grep shells out via std-sync Command::output — running it on the async-runtime workers would pin a worker. Initial-query seed plumbing: LivePickerQueryState.initial_query: Option<String> set by open_picker, consumed (taken) by seat_picker_from_pairs so :picker grep TODO opens with query = "TODO" ready to extend. Tests: grep_source_empty_args_returns_empty_inline, grep_source_on_query_changed_empty_short_circuits, grep_source_spec_is_live, open_picker_grep_no_args_installs_live_state, open_picker_grep_with_initial_pattern_stashes_query_until_seat, picker_grep_seeds_query_on_seat_when_initial_query_stashed.
live-pkr.4Docs. ../architecture/design.md §5.9.7 gains a "Live sources" subsection contrasting the v1 debounced-future shape against the future fully-streaming model; reasons why grep specifically fits Future-per-query rather than incremental Stream. Test-counts row updated.

Deferred (post-1.0): result-cap echo when grep hits max_hits and truncates; in-row match-range highlights (the grep backends emit column info — messages_line_spans-style span-builder would work); per-source is_live() overrides for future sources that want live behaviour conditionally (e.g. workspace-symbols falls back to static when the server has no workspace/symbol).


picker-help (per-picker <C-h> help pages, 2026-09-24)

<C-h> in a picker closes it and opens that picker's own :help page; ascend moved from <C-h> to <C-w> (delete-word where a source has no depth). Design: ../architecture/picker.md §4.2quinquies. Slice plan: slice-plans/archive/picker-help.md — PH.1–PH.5 ✅ (25 pages: every builtin source, the id-less LSP / AI / :b pickers, one shared magit page, and the project plugin's two through its help seam; guarded by picker_help_pages_cover_every_source.rs); PH.6 (transient menus) ❌ dropped — a transient menu is its own help. Plan complete.


Vim grammar coverage (Phase 1 catalog)

This section enumerates every named primitive in vim's grammar against its status here. Anchor: ../architecture/design.md §5.2 + the seven unifications in §5.10–§5.12.

Multi-cursor (native, 📝 MC.1–MC.15): design in ../architecture/multi-cursor.md, sequencing in slice-plans/multi-cursor.md.

StateStatusAnchorNotes
Normal§5.2Plus block cursor in TUI
Insert§5.2Plus bar cursor
Visual (Charwise)§5.2, B.1Selection extends, operators on Range::Selection
Visual (Linewise)§5.2
Visual (Blockwise)✅ d/y/c/I/A/>/< + paste§15:18Ctrl-V (or Ctrl-Q on terminals that hijack Ctrl-V) enters; render highlights the rectangle; d / y / c dispatch per-row in the dispatcher with merged Edits + one Blockwise yank. > / < indent each covered line. I / A enter Insert at the block's left / right column on the top row; on Esc the typed prefix is replicated to every other row at the same column (rows shorter than the column are skipped, vim's behavior). YankKind::Blockwise paste replays each row at the same column.
Operator-Pending§5.2Resolved through translate_normal pending state
Command (:)§5.9.10Rich minibuffer scope is partial; full spec is post-Phase-1
Search (/, ?)§5.9.10Live preview wired (hlsearch on every keystroke); fancy-regex backend
Replace (R)§5.2Backspace-restore wired

Motions (Reflex-class)

MotionKeyStatusAnchor
char_left / char_righth, l§5.2.2
line_up / line_downk, j§5.2.2
line_start / line_end0, $§5.2.2
first_non_blank^§5.2.2
word_forwardw§5.2.2
word_backwardb§5.2.2
word_ende§5.2.2
WORD_forward / backward / endW, B, EWhitespace-delimited variants
paragraph_forward / backward}, {§5.2.2
sentence_forward / backward), (
goto_first_line / goto_last_linegg, G§5.2.2
find_char_forward / backwardf, F§5.2.2
till_char_forward / backwardt, T§5.2.2
find_repeat / find_repeat_reverse;, ,
viewport_top / middle / bottomH, M, LApp-level (needs viewport_height)
word_search_forward / backward*, #§B.3 informally
match_bracket%App-level
jump_history_back / forwardCtrl-O, Ctrl-I§5.1.1 unified ring (filtered to AutoJump+PluginPush)
mark_history_back / forwardg;, g,§5.1.1 unified ring (filtered to NamedMark)
page_down / page_upCtrl-F, Ctrl-BApp-level
scroll_line_up / downCtrl-Y, Ctrl-EApp-level
half_page_down / upCtrl-D, Ctrl-UHardcoded count 10
mark jumps'a, `a§5.1.1

Operators (Reflex-class for sync prelude)

OperatorKeyStatusAnchor
deleted, dd, D§5.2.2
changec, cc, C§5.2.2
yanky, yy, Y§5.2.2
indent_left<§5.2.2
indent_right>§5.2.2
uppergU§5.2.2
lowergu§5.2.2
toggle_caseg~§5.2.2
filter!Subprocess pipe; depends on :!cmd (§B.6)
formatgqDepends on plugin / formatter
join_linesJ, gJApp-level (not a grammar operator)

Text objects

Text objectKeyStatusAnchor
inner_word / around_wordiw / aw§5.2.2; alphanum + _ run
inner_WORD / around_WORDiW / aWWhitespace-delimited; punctuation kept (foo.bar is one WORD). Shares text_object_inner_word_class / _around_word_class with iw / aw -- only the byte classifier differs (is_big_word_byte).
inner_quote_dbl / aroundi" / a"
inner_quote_sgl / aroundi' / a'
inner_quote_btk / aroundi` / a`
inner_paren / aroundi( / a(
inner_bracket / aroundi[ / a[
inner_brace / aroundi{ / a{
inner_angle / aroundi< / a<<…> pair; reuses text_object_inner_brackets / _around_brackets. Both < and > resolve to the same target.
inner_tag / aroundit / atXML/HTML tags
inner_paragraph / aroundip / ap
inner_sentence / aroundis / as

Counts, registers, ranges, marks, macros, dot-repeat

FeatureStatusAnchor
Numeric prefix counts (3w, 5j, 2dw, 2d3w)§5.2.2
Register prefix "<reg>§5.2.2
Named registers "a-z, "A-Z✅ (uppercase replaces, append TBD)§5.2.2
Numbered registers "0-"9✅ (storage; "0 auto-populate TBD)§5.2.2
Black hole register "_
System / clipboard "+, "*✅ OS clipboard wired (CB series)clipboard.md
Last-yank "0⚠️ readable; auto-populate TBD
Ex-command ranges (1,5 / % / 'a,'b / patterns)🟡 % and CurrentLine work§5.2.2
Marks (m / ' / `)§5.1.1
Mark for last visual ('<, '>)
gv (reselect last visual)
Macros (q, @, @@)§5.2.4
Dot-repeat (.)§5.2.4
Insert-mode replay in dot-repeat§5.2.4

Search and substitution

FeatureStatusAnchor
/ ? n N regex search with wrap§5.9.10; backed by fancy-regex (DFA + bounded NFA fallback for backrefs)
* / # word-searchWord is regex-escaped before compile so literals containing . * ( etc. don't trigger metachars
Search highlight in buffer§5.6.2
:s/PAT/REPL/[g] substituteFull regex; replacement template uses $1/${name}/$0 (regex crate / fancy-regex syntax, not vim's \1)
Pattern backrefs (/(\w+).*\1/)fancy-regex NFA path; bounded by 1M-iteration recursion limit
Replacement backrefs (:s/(a)(b)/$2$1/)$1 etc. via fancy-regex's replace_all template
Search-as-you-type live preview (hlsearch)every match highlighted; persists after submit; compile errors silenced during preview
Substitute-as-you-type live preview../architecture/design.md §5.9.10; magenta strike-through overlay on matches as the user types :s/pat/repl/...; honours /g flag and %s scope
Search cooperative cancellation../architecture/design.md §5.2.5; search loops poll a CancellationToken per chunk + per match; flipped token returns CoreError::Cancelled
Per-search deadline timerthe cancellation seam is in place; the deadline-flipper (Reflex < 2 ms) is the remaining piece

Ex commands

Unification status (../architecture/design.md §5.2.1, §B.2): every ex-command is now a registered ExCommandSpec peer of motions / operators / text objects. The : parser front-end resolves aliases, looks up by name, calls the spec's parse_args (or builds an Args::List directly for the delimiter-syntax forms :s/.../, :%s/.../, :g/.../, :v/.../), and dispatches through the unified grammar::execute(). Apply closures emit Effect variants (SaveBuffer, QuitEditor, OpenBuffer, SetOption, Substitute, Global, Echo, ...); App::apply_effect owns the side effects.

Kind-prefix form on : (§5.2.1 closure) ✅. Every command -- motion, operator, text-object, ex-command, plugin contribution -- is reachable from : by the :<kind> <name> form. Three kind words (motion, operator, text-object) are reserved on the : line and disambiguate the namespace; ex-commands keep their bare alias surface unchanged.

:motion goto-first-line               # naked motion
:operator delete word-forward         # operator + bare target (motion)
:operator delete inner-word           # operator + bare target (text-object)
:operator delete motion:word-forward  # full canonical form (disambig)
:text-object inner-word               # errors helpfully
:write foo.txt                        # ex-command, vim alias surface

Operator targets resolve via implicit-namespace lookup: the bare tail is tried as motion:<tail>, then text-object:<tail>, then as a full canonical name. Plugin-registered names (e.g. motion:my-plugin:fancy-jump) are reachable via :motion my-plugin:fancy-jump -- the second-word colon is part of the tail, not adjacent to the cmdline colon. See crates/lattice-ui-tui/src/excommand.rs::parse_kind_prefixed.

: surface invariant. ../architecture/design.md §2.2 explicitly excludes a function-call / palette / scripting syntax on :. The : line is vim's ex-syntax DSL; code paths (plugins, init.rs, the Rust functional API) construct CommandInvocation directly via the WIT host. Two input surfaces, one dispatcher.

Surface-form gating. Each ExCommandSpec carries a surface_form: SurfaceForm (Keyword or Delimiter { hint }). Delimiter-form commands (ex:substitute, ex:global) are routed by the parser's delimiter-detection pass; the keyword form (:ex:global) returns WrongSurfaceForm { name, hint }, which surfaces the intended syntax (:g/pattern/body (or :v/pattern/body for inverted)). The gen:commands completion generator filters delimiter-form commands so the cmdline popup never offers a candidate that would error when accepted. They remain reachable via :describe-command / :apropos for introspection.


Introspection architecture (../architecture/design.md §5.11)

Help is buffer-backed from day one, modeled after emacs's *Help*. A HelpBuffer (in lattice-ui-tui::help) holds a real lattice_core::Buffer (rope) plus the title, scroll offset, a real cursor (Position), and an extracted Vec<HelpLink>. The popup overlay treats the help buffer as a buffer: j k h l 0 $ gg G Ctrl-D Ctrl-U move the cursor and scroll auto-follows. The terminal cursor is rendered at the screen translation of help.cursor so the user sees their position. HelpDisplayMode enumerates Popup / Split / Tab / Window for the per-user display preference; the popup overlay is one strategy and stays available for transient surfaces (hover, future doc lookups, error toasts).

Help also lives in the unified [BufferRegistry] as BufferData::Help(HelpBuffer). App::open_help_in_pane(buffer) allocates a BufferId, inserts the registry entry, and swaps the active pane to it -- the in-pane counterpart to App::open_help (popup). The registry holds the durable record (:ls / :bn / picker discovery); App.help_buffer mirrors the active in-pane copy so the existing keymap + render paths stay single-path. Pane-switch hooks (snapshot_active_pane / load_active_pane) sync the two at boundaries, mirroring the Document buffer pattern (syntax/folds snapshots). De-dup is by title -- re-running :lsp-log rust surfaces the existing buffer rather than allocating a duplicate. Persistent help views (LSP logs, :diagnostics, :apropos, :describe-*) migrate to this path in Phase 3 alongside the picker (Phase 2).

Provenance (§5.11.1)

Every registration / binding / set captures a SourceLocation (lattice-grammar::source). :describe-* output always includes a [[file:...]] link to where the thing came from -- vim's :verbose set semantics, applied uniformly across commands, keys, options, events, modes.

CommandStatusAnchor
:w / :write [path]✅ registry§5.2.1
:q / :q!✅ registry§5.2.1
:wq / :x / :wq! / :x!✅ registry§5.2.1 (Effect::Many)
:e / :edit [path] / :e!✅ registry§5.2.1
:d / :delete✅ registry§5.2.1
:noh / :nohl / :nohlsearch✅ registry§5.2.1
:reg / :registers✅ registry§5.2.1
:marks✅ registry§5.2.1
:set option=value§5.12. Typed-options registry in lattice-config (renderer-agnostic): OptionType trait + Option<T> with ArcSwap<T> cell + typed OptionHandle<T>. Core options (number, relativenumber, wrap, ignorecase, tabstop, foldenable, foldmethod, scrolloff, completion.auto_insert_single) register from lattice-config::register_core_options; renderer options register from each renderer crate. App integration: do_setconfig.parse_and_set_commandapply_post_set for cascades (relativenumber ⇒ number, foldmethod ⇒ recompute, ui.* ⇒ theme refresh).
:s/.../.../[g]✅ registry (regex)§5.2.1 / §B.2; Args::List([pattern, replacement, flags]), scope via Range::CurrentLine/Whole. fancy-regex compile + per-line replace_all; replacement template uses $1/${name}/$0.
:g/pattern/cmd✅ registry§B.2; Args::List([pattern, false, ArgValue::Invocation(body)]). Body parsed once at :g parse time (no per-match re-parse); body syntax errors surface before :g fires.
:v/pattern/cmd✅ registry§B.2; same shape as :g, inverted flag set.
:describe-command✅ buffer (popup)§5.11; renders CommandSpec.doc + each args_schema entry's name/kind/doc/default.
:describe-buffer✅ buffer (popup)§5.11; path / language / modal / cursor / dirty / line-count / registers / marks / position-history / macros / folds / view options.
:describe-key ✅ buffer (popup)§5.11; renders every KeymapEntry for the chord through the unified Introspectable surface. Each binding shows Bound at: [[file:keymap.rs:LINE]] (built-in) -- the row's source captured by the keymap_entry! macro -- plus an Action: [[command:...]] cross-reference. The chord arg uses ArgKind::Chord: typing :describe-key <space> puts the cmdline into chord-capture mode (raw key events render as canonical tokens -- Ctrl-c -> <C-c>, Up -> <Up>); :describe-key<CR> with no arg arms a one-shot prompt and the very next chord auto-submits.
:keymap✅ buffer (popup)§5.11; lists all default bindings grouped by mode, every chord linked via [[key:...]] for follow-up :describe-key.
:apropos ✅ buffer (popup)§5.11; case-insensitive substring over every CommandSpec.name + doc. Picker UI (§5.9.7) is post-1.0.
:describe-option§5.11 / §5.12. Reads from lattice-config::ConfigRegistry's ErasedOption view: name, aliases, type label, current value, default value, enumerated values (where applicable), doc body. Markdown-rendered into a help buffer.
:describe-event, :describe-mode§5.11; each lands when its registry does (event bus §5.10 / modes Phase 8).
Command-line history (Up/Down)§B.3
:history-*§B.3 (picker UI; Up/Down already works)
:customize§5.12
:autocmd / :add-hook§5.10
Capture mechanismUsed forStatusForgery resistance
#[track_caller] on register_*built-in command registrations in builtins.rs / ex_commands.rs -- zero call-site burden✅ implementedcompiler-captured, caller cannot supply or override
keymap_entry! declarative macrostatic keymap rows (per-row file!() + line!())✅ implementedmacro is the only construction path; source field is pub(crate)
Trusted subsystem builds valueconfig loader, plugin host bridge, runtime dispatcher⛔ deferred (no trusted subsystems exist yet)pub(crate) insert_* registry methods exist; sibling crates will use sealed-trait re-exports when needed
SourceLocation::synthetic (cfg-test only)test fixtures✅ implementedinvisible outside tests

Public API invariant: there is no pub fn that takes a SourceLocation parameter and stores it. Verified by:

  • The register_* methods are all #[track_caller] and don't accept a source.
  • The pub(crate) insert_* companions are visibility-gated to lattice-grammar.
  • The keymap_entry! macro is the only path that constructs a KeymapEntry; its source field is pub(crate).

Determinism: a unit test (track_caller_captures_register_motion_call_site) registers a sentinel at a known line and asserts the captured SourceLocation matches exactly. Six related tests cover propagation through #[track_caller] helpers, the negative case where unmarked helpers shift the captured line inward, and per-row line distinguishing across adjacent registrations. Any refactor that breaks call-site capture (a dyn Fn dispatcher around register_*, removed #[track_caller] on a helper in the chain) fails CI.

Generic renderer (§5.11.2)

Every :describe-* target implements Introspectable (lattice-grammar::introspect). One generic render_introspection(&dyn Introspectable) -> Vec<String> produces the help body in a uniform shape: identifier + kind + doc + type-specific extra_sections + one [[file:...]] link per labelled source (Defined at:, Bound at:, Subscribed at:, Last set at:, Overridden at:, Activated at:). Adding a new introspection target -- when typed options / events / modes land -- is one trait impl plus one new ex-command; the renderer doesn't change.

Completion pipeline (§5.11.3)

lattice-completion is a standalone crate with its own test corpus (81 tests) wired into the :-line via the App's completion_registry + completion_state. Four pluggable stages, vertico-shaped:

StageTraitBuilt-ins
GenerationCandidateGeneratorgen:commands, gen:files (host-state generators in lattice-ui-tui)
MatchingCandidateMatchermatch:prefix (default), match:substring, match:fuzzy
RankingCandidateRankerrank:score (default), rank:alphabetical
AnnotationCandidateAnnotatoranno:kind-label, anno:doc-snippet

CompletionRegistry registers each stage with #[track_caller] provenance. CompletionPipeline::run(ctx, query, cache) walks the four stages and returns a Vec<RenderedCandidate> for the renderer. Slot detection (current_slot) parses the :-line into CommandLineSlot::CommandName, Arg { command_name, arg_index, arg_spec, .. }, DelimiterBody { command_name, body }, UnknownCommand, BeyondSchema, or Empty.

Caching is opt-in per generator via cache_key() returning Option<CacheKey>. gen:commands returns a fixed "gen:commands:v1" key (commands don't change at runtime in v1, so cached effectively forever); gen:files keys per-directory with a 1-second TTL.

Composability is structural: every stage is a trait, plugins register impls against the same registry as built-ins, the default matcher / ranker / annotators are configurable per-user (cmdline.matcher = "match:fuzzy" post-§5.12). The pluggable shape mirrors emacs's vertico / orderless / marginalia / consult -- composability by design, not retrofit.

Forgery resistance mirrors the §5.11.1 invariant: no public API takes a SourceLocation parameter. register_* are all #[track_caller]; pub(crate) insert_* companions exist for trusted subsystems (config loader, plugin host bridge) but deferred until first cross-crate trusted subsystem lands.

The crate doesn't depend on lattice-ui-tui, so it's tested independently. CI runs cargo test -p lattice-completion as its own line item.

App integration:

CapabilityStatusNotes
<Tab> opens completion popupSlot-aware: command-name slot uses gen:commands; arg slot uses arg_spec.completion.
<Tab> advances candidates while openwraps at end
<S-Tab> moves backwardwraps at start
<CR> accepts selectedreplaces [replace_start, end) with the candidate's text
<Esc> two-stage dismissfirst Esc closes popup; second cancels cmdline
Typing dismisses popupre-trigger with Tab for fresh candidate set
<C-h> describe under cursorhybrid: word-at-cursor describes self if registered; else parent command at arg:<name> anchor
<C-u> clear cmdlinealso dismisses popup
<C-w> delete trailing wordstrips whitespace then last token
Vertico-style popup renderbordered, anchored BELOW the : prompt (cmdline shifts up to make room), selected row highlighted, matched byte ranges painted, annotations right-aligned
Alias-preferred candidate textgen:commands returns canonical names (ex:describe-command); host post-process rewrites to the user-facing alias (describe-command) and recomputes match ranges. Parser accepts both forms via two-stage resolution.
Single-candidate auto-insert:set completion.auto_insert_single (default on). When <Tab> would open a popup with exactly one candidate, applies it directly instead. Fires only at popup-open boundary; narrowing an open popup to one candidate while typing does NOT auto-insert (vim-style; less surprising). Phase 4.2 (#199) should reuse App::open_completion_popup (or factor a shared helper) when wiring Insert-mode / LSP completion so this option stays universal without a second knob.
Help overlay dismissed when entering :Q16: user can only focus on one thing
<C-b> / <C-e> cursor movement⛔ deferredneeds full cmdline-cursor refactor; v1 cursor stays at end
<C-r> register paste⛔ deferredneeds Pending::AfterCommandLineRegister substate
Completion inside :s/.../.../ body⛔ deferredDelimiterBody slot returned; renderer doesn't recurse into pattern/replacement/flags

ArgSpec.completion: Option<&'static str> field naming a registered generator. v1 wires gen:files for :write/:edit paths and gen:commands for :describe-command's name arg. Adding gen:options when typed options land is one schema edit per command.

The default matcher (match:fuzzy) is set at registry construction; users / configs can swap to match:prefix or match:substring once typed options land via cmdline.matcher = "match:prefix".

Link markup -- forward-compatible reference syntax in help bodies:

MarkupResolution
[[command:NAME]]re-dispatch :describe-command NAME
[[key:CHORD]]re-dispatch :describe-key CHORD
[[file:PATH:LINE]]open PATH at LINE
[[anything-else]]Unresolved (preserved verbatim)

The popup renderer is dumb today: links render verbatim. The follow-link motion + styled link ranges + [[file:...]] source navigation arrive incrementally:

CapabilityStatusNotes
Buffer-backed help (rope content)HelpBuffer.content: Buffer
Link markup defined + parsedparse_help_links returns Vec<HelpLink> with byte ranges
Help formatters emit links:describe-key, :apropos, :keymap reference cross-targets
Display: Popup overlaytransient surface (hover, doc lookups); reachable via App::open_help
Display: In-pane (registry-tracked)BufferData::Help + App::open_help_in_pane + activate_buffer Help arm; call-site migration to in-pane (:lsp-log, :describe-*, :diagnostics) follows in Phase 3
Display: Split / Tab / Window🟡in-pane lands with active pane today; multi-pane (split into a sibling pane) follows the picker / Phase 3
Vertico-style picker primitive (§5.9.7)lattice-ui-tui::picker::Picker -- query line + substring filter + selection cursor + PickerAction accept tag. Renderer-agnostic data model: the only imports are stdlib + lattice_completion; host-coupled candidate builders live on the host side (the buffer-source builder is app::raw_buffer_candidates; the LSP-source builder is LspInstanceRow::into_candidate fed by host-snapshotted rows). Picker::set_raw_candidates is the single mutation entry point. Drops into a sibling crate lattice-picker with no source edits when a second renderer needs it. Sources: Buffers, LspInstances { prefilter }. First instantiations: :b buffer switcher; :lsp-log / :lsp-server-log / :lsp-trace-log LSP picker (Phase 3). Layout: vertico-style (query takes over cmdline row; candidates render in band immediately below, selected row closest to prompt, no border). Live preview-in-pane for SwitchToBuffer (selection-change activates the buffer in active pane without pushing position history; <Esc> restores preview_origin). Pipeline-driven matcher / ranker / annotators graduate the picker in a follow-up.
Cmdline tab-completion: vertico-styled: line tab-completion popup converged with the picker's visual shape. No bordered box / title; candidates render flush in the row band below the cmdline, reusing candidate_to_line. The cmdline appends a faint (n/m) count suffix when the popup is open, mirroring the picker prompt's inline indicator.
LSP picker -- log / trace dispatch:lsp-log [server], :lsp-trace-log [server], :lsp-server-log route through App::open_lsp_picker (Phase 3). Single-match short-circuit opens directly; multi-match opens the picker pre-filtered. Opened buffer goes through open_help_in_pane. :lsp-trace is now a pure toggle (no buffer-open side-effect).
LSP log buffers live-tail (Phase 4)Event::LspLogPushed fires on every LspLogger::log append; App::drain_lsp_log_events (called per main-loop tick) refreshes any open *lsp* / *lsp:<server>* / *lsp:<server>:trace* help buffer from a fresh logger snapshot. Coalesces by scope so a burst of N records resolves to one rebuild per affected scope. Cursor + scroll preserved across the rebuild (clamped to new line bounds); popup hot-path mirror synced.
LSP server-name resolution:lsp-trace, :lsp-log, :lsp-trace-log accept either the canonical actor id (rust) or the binary name the user recognises (rust-analyzer); resolution priority: running actors -> registered config ids -> config binary file-name (with .exe stripped). Unknown names echo the running id list so the user can disambiguate.
Pane-aware viewport heightApp::active_pane_content_height(buffer_height) returns the active pane's content rect (subtracting the per-pane status row in multi-pane mode). Runtime feeds it into set_viewport_height so motions / scroll / fold-aware ensure-cursor-visible agree with what the renderer paints. Without this, horizontal/vertical splits clipped the lower / right half of the active pane.
K / gd registered in keymap registryThe input translator already dispatched K -> LspHoverRequest and gd -> LspDefinitionRequest, but the keymap registry didn't list them, so :describe-key K / :apropos hover came back empty. Added entries in keymap::build_default_keymap.
Styled link ranges in rendererrenderer ignores links today
Follow-link motion (e.g. <CR> on link)needs tree-sitter help grammar + link motion
Help major mode + tree-sitter grammarshipped; see docs/user/help-mode.md
SourceLocation on CommandSpecneeds register_* API extension; powers [[file:...]] auto-emit
:source-of <command>depends on SourceLocation
:describe-keykeymap registry §5.2.3 -- see below
:keymapfull default keymap, grouped by mode
:describe-optiontyped options registry §5.12 shipped -- see the :describe-option row above
:describe-eventneeds event bus §5.10
:describe-modeneeds major/minor modes (Phase 8)

Keymap registry (../architecture/design.md §5.2.3)

KeymapEntry { chord, mode, doc, command } in lattice-ui-tui::keymap, populated as a &'static [KeymapEntry] DEFAULT_KEYMAP covering every chord in input.rs. v1 is a descriptor table the introspection layer queries; the input layer (input.rs::translate) still owns the chord-to-Action translation. A drift test (keymap_descriptors_dont_drift_from_translate) walks every descriptor through translate() and asserts a non-None Action -- catches removed/moved bindings before they ship.

Registry-driven dispatch (the input layer consuming the keymap registry rather than running a parallel match) is post-1.0 -- it needs the layered keymap walker (built-in / major-mode / minor-modes / user / per-buffer) from §5.2.3. The descriptor table is the migration seed.


Async / actor architecture (../architecture/design.md §5.2.1, §5.6.8, §5.7)

The async core lands in lattice-runtime. Every document is owned by its own tokio task (the document actor); mutations route through a bounded mpsc mailbox; commits publish an immutable DocumentSnapshot to an arc_swap::ArcSwap cell that any reader (renderer, future LSP client, future plugin) loads wait-free.

lattice_grammar::execute stays a pure sync function (no tokio dependency, no async signature). The actor calls it inside its own task via the Dispatch message; only the boundary between caller and document is async.

App talks to the actor through DocumentHandle (cheap Clone -- an mpsc::Sender + Arc<PublishedSnapshot>). The TUI input loop, which is a blocking crossterm poll, bridges sync→async via lattice_runtime::block_on. Every mutating method returns Pending<T> (a typed wrapper around oneshot::Receiver); the App's *_blocking helpers concentrate the bridge in one place.

Publish-before-reply. The actor's run loop publishes the new snapshot before sending the oneshot reply for any mutation. This guarantees that any caller observing the reply also observes the new snapshot via arc_swap::load -- without this ordering, callers can race past their own commit.

This is Phase 4 / 7's prerequisite — LSP clients and the WASM plugin host can now share DocumentHandle with the App; both register against the same actor, both observe the same snapshot stream. The remaining ⛔ rows (cancellation, latency classes, hook classification, event bus) layer on top of the actor without restructuring it.


Performance posture

ConcernStatusAnchor
Document actor / bounded mpsc mailbox§5.7 (lattice-runtime::DocumentActor)
Pending<T> returned by every mutating call§5.2.1 (lattice-runtime::Pending)
Bounded backpressure (RuntimeError::Busy)§5.2.1 (mailbox cap = 64)
arc-swap published DocumentSnapshot§5.6.8 (PublishedSnapshot)
Renderer reads via single snapshot load per frame§5.6.8 (render::draw_*)
Publish-before-reply ordering§5.6.8 (acquire/release contract)
Sync lattice_grammar::execute (runs inside actor)§5.2.1 (purity preserved)
Latency-class declarations (Reflex / Display / Background)✅ declarative§5.2.5 (LatencyClass field on CommandSpec; runtime enforcement deferred)
Cancellation token contract✅ user-Esc§5.2.5; CancellationToken (Arc) plumbed through dispatch_with_cancel → grammar dispatcher → operator/motion/text-object contexts → search loops. Deadline-timer flipper (Reflex < 2 ms, Display < 10 ms) is the remaining piece.
Foreground cancellation (<C-g>)🚧 CG.1 donedesign · slice plan. CG.1 ✅ (2026-08-07): Editor.active_cancel + arm_cancel() / cancel_foreground() / reset_to_normal(); AppEffect::CancelAction::Cancel → host-resident dispatch arm; action:cancel bound to <C-g> at KeymapLayer::Builtin for Normal/Insert/Replace (+ Command/Search/Prompt via the Insert table) in keymap_cancel.rs. Visual/Select keep SN.3d's <C-g> toggle and have no cancel chord (<Esc> then <C-g>). <C-c> is deliberately NOT used — it is a mode prefix (<C-c>g, <C-c><C-c>) and KeymapTrie::lookup resolves a terminal node before its children, so a depth-1 binding makes those chords unreachable (design §4.5.1); <Esc> was rejected because vim users press it reflexively (§4.5.2). No plugin (WIT) surface. CG.2 ✅ (2026-08-08): the arming surface is a registered ForegroundCancelHandle service (lattice-mode), armed from any &self context — most spawn sites (action-handler closures, event subscriptions) never see &mut Editor. Project search's private Arc<AtomicBool> supersede flag is gone; ProjectSearchState.cancel_token is the armed token, and arm() cancelling its predecessor makes supersede and cancel one mechanism. CG.3 ✅ (2026-08-08): ForegroundCancel gained enrol() alongside arm() — twelve user-triggered LSP commands join the foreground set so <C-g> reaches them, while keeping their per-feature supersede tokens (a hover must not cancel a rename). Automatic requests (maybe_request_*, completion, signature-help, on-type-format) are deliberately excluded per design §3 — arming them would make every keystroke cancel a running search. CG.4 📝: the WASM fuel trampoline. CG.5 ⛔: token stack + status-line badge, deferred behind the progress subsystem.
Event bus (observation baseline)§5.10; EventBus in lattice-runtime: kind-indexed dispatch, SubscriptionTarget::Channel (mpsc) + Invocation (queued via drain_pending_invocations).
App-side event publish§5.10; App publishes DocumentChanged (apply_edit / batch / undo / redo), SelectionsChanged (set_selections), ModalModeChanged (only on actual axis movement), BeforeSave + DocumentSaved (sync wrapper around save / save_as), BeforeQuit (Action::Quit + :q after dirty-check), OptionChanged (every typed-options registry write — including :set foo=bar, :set nofoo, and direct config.set paths; carries canonical name + old + new formatted strings).
Config → event bus bridge§5.10 + §5.12; lattice-config::ConfigRegistry exposes set_event_publisher(EventPublisher). App wires the bus at boot via a closure that calls event_bus.publish(event). Subscribers see option changes through Event::OptionChanged instead of polling.
App-side cascade via bus subscription§5.10 + §5.12. App subscribes a tokio::sync::mpsc::UnboundedReceiver<Event> filtered to EventKind::OptionChanged at boot; App::drain_option_changes consumes it and runs the per-option cascade (relativenumber⇒number, foldmethod⇒recompute_folds, ui.*⇒sync_theme_from_config). Drained at the end of do_set (synchronous user-visible behaviour preserved) and at the top of every main_loop iteration (backstop for writes outside the keystroke path -- plugin tasks, customize buffer, init.rs). The chained cascade case (relativenumber⇒number itself fires another OptionChanged) is handled by the drain's while let Ok loop. No registry-mutex re-entrancy risk: publisher closure runs after the registry drops every lock.
Veto-class hooks (1ms p99)§5.2.1 (needs Before-event return-path so handlers can mutate / abort; v1 publish is observation-only)
Events-over-invocation rule§5.2.5 (needs :autocmd and add-hook parser front-ends to desugar into subscribe)
Interactive arg-prompts (§B.1 phase 2)Submitting bare :cmd<CR> with a Required first arg arms a prompt: prefills :cmd , surfaces the schema's prompt in the echo area, and waits for typed input (Chord-kind args additionally auto-submit on the next captured chord). Optional-default args take the parser's normal path.
Multi-pane selection transformationn/a (single-pane v1)§5.6.8
ConcernStatusAnchor
Criterion bench harness§8.2
Render hot-path is viewport-bounded§8.2 (this commit)
Actor / runtime benches§5.6.8 / §8.2
LatencyClass declaration on CommandSpec§5.2.5
Test + clippy CI gate(.github/workflows/ci.yml)
Bench-compile CI gate(catches bench rot)
Bench baseline recording (push to main)(artifact upload, no diff yet)
Bench regression detection (>10% threshold)§8.2 -- needs stable runner
Per-class budget assertions in CI§5.2.5 -- needs cancellation/deadline machinery
Allocation discipline check in CI§A.6
Long-running session benches§A.6
Cross-platform acceptance suite§A.6

Render hot path. compose_visible_lines previously did buffer.as_string().split('\n').collect::<Vec<String>>() once per frame -- O(buffer size) bytes per paint, blowing the §8.2 <2ms frame budget on any non-trivial buffer. Now uses ropey's O(log n) per-line API via Buffer::line(idx) and materializes only the visible window (height lines, typically 50). 100MB log files now pay the same per-frame cost as 100-line files.

Actor benches (crates/lattice-runtime/benches/actor.rs) characterize the load-bearing async primitives:

BenchmarkDESIGN target (p99)Observed (median)
apply_edit round-trip<100µs~80µs (constant across 10/1k/50k lines)
snapshot_load (load_full)<20ns~17ns (Arc bump path)
snapshot_load_cached (Cache::load, steady)<500ps~305ps
snapshot_post_publish_read--~17ns

The apply_edit round-trip meets §8.2 with 20% margin. The renderer now reads through arc_swap::Cache::load per frame (~50× faster than load_full) -- App holds a SnapshotCache rebuilt on each document switch, the runtime calls app.snapshot_cache.load_arc() once per frame in runtime.rs, and the resulting &DocumentSnapshot is threaded through the active-pane render path (compose_visible_lines, cursor_screen_position, closed_fold_display_span, buffer_line_to_visible_row, draw_mode_line). Inactive panes render different documents and still go through entry.handle.snapshot() (load_full); a per-doc cache map for inactive panes is queued behind a profiling motivator. The 50+ app.document.snapshot() call sites in keystroke handlers remain on load_full -- each runs ≤1× per keystroke, dominated by other work, so the migration there is deferred.

LatencyClass declaration (../architecture/design.md §5.2.5) is now a field on every CommandSpec. :describe-command surfaces it under a "Latency:" section. v1 is purely declarative; the cancellation / deadline machinery that enforces it lands with the §5.10 event-bus. Default classifications:

  • Reflex (<2ms p99): every motion, operator, text-object; cheap ex-commands (:quit, :noh, :set, :delete, :s, :g, :v).
  • Display (<10ms p99 sync prelude): file I/O (:write, :write-quit, :edit); help-buffer builders (:reg, :marks, :describe-*, :apropos, :keymap).
  • Background: none yet -- the indexer / file-watcher / LSP debounce paths arrive in Phases 4-7.

CI (.github/workflows/ci.yml) gates every push/PR on:

  • cargo test --workspace --locked + cargo clippy --workspace --tests --locked -- -D warnings across a cross-platform matrix (ubuntu-latest, macos-latest, windows-latest; fail-fast: false).
  • cargo fmt --all -- --check -- rejects unformatted code.
  • cargo doc --no-deps --workspace with RUSTDOCFLAGS=-D warnings -- catches broken intra-doc links before merge.
  • cargo bench --workspace --no-run per platform -- bench-compile rot detection.
  • bench-baseline on push-to-main runs the benches in --quick mode and uploads the criterion reports as artifacts. Groundwork for the regression gate when stable bench infrastructure (self-hosted runner or bencher.dev) lands.

Current bench coverage: motions (word_forward / backward / end / first_non_blank / counted), operators (dw / dd / d_whole / yw / cw / diw / di_paren), search (forward first / last / no-match-with-wrap / backward), buffer (insert at origin / middle, delete one byte, position round-trip), runtime actor (apply_edit round-trip / snapshot_load / snapshot_post_publish_read at 10/1k/50k lines).


Terminal-mode (issue #40, 2026-05-22 → 2026-05-25)

Plan + tracker live in slice-plans/terminal-mode.md; design in ../architecture/terminal-mode.md. Capsule summary so this ledger stands alone:

  • T1 — PTY spawn (portable-pty); homegrown cell-grid reader; new BufferKind::Terminal integrated everywhere buffers flow (:terminal / :tnew, picker <C-s> / <C-v> / <C-t>, pane-render dispatch, modeline / tabline / :ls); :bd! kills the child.
  • T2 (substrate) — homegrown reader retired; alacritty's Term + vte::Parser is the engine. SGR colours (named / 256 / truecolor), alt-screen, cursor visibility, DECCKM all honoured. Cells published with proper TerminalColor / CellAttrs; both renderers (TUI / GPUI) paint per-cell fg/bg with adjacent-run coalescing.
  • T2.a / T2.b / T2.b.0 / T2.cTerminalInsertMode minor mode; full ANSI encoder (printable + Enter/Tab/Backspace
    • arrows + Home/End/PgUp/PgDn + Insert/Delete + F1–F12 + Alt-prefix + xterm 1;<n> modifier parameter); i/a/I/A entry; <Esc> exit gated by terminal.esc-exits (default true); modeline TERMINAL-INSERT; <C-c> exemption sends SIGINT. T2.c (2026-05-25): <C-w> window-prefix in Normal-in-terminal works via the standard keymap (MotionAdapter cleanup); <C-w> WERASE inside Insert via the encoder's ctrl_char_byte('w'); <C-\><C-n> two-key chord (<C-\> arms, <C-n> confirms; anything else sends \x1c + that key's PTY bytes); DECCKM bit propagates via SharedTerm::cursor_keys_application_modekey_to_ansi_with_mode(_, true) so arrow keys flip to SS3 when programs enable application-cursor-keys.
  • T3 (scrollback + nav + copy)terminal.scrollback-lines typed option (default 10k); alacritty history ring sized through it; SharedTerm handle shared between reader + dispatch; snapshot publishes scroll_offset / scrollback_rows. MotionAdapter cleanup retired every kind-specific branch from the translate layer: standard Normal-mode keymap flows through Editor::run_terminal_invocation (a peer of run_help_invocation / run_oil_invocation) which routes line_down / line_up / goto_first_line / goto_last_line / page_down / page_up / scroll_line_down / scroll_line_up to SharedTerm::scroll. Search: /pattern<CR> / ?pattern<CR> / n / N use SharedTerm::find_match (forward / backward) and find_all_matches for hlsearch. Current match paints reverse-video; all matches in the visible window paint with underline. Visual: v / V / <C-v> enter charwise / linewise / blockwise on the cell grid; h/j/k/l/gg/G extend; y yanks with matching YankKind so cross-buffer paste handles correctly; <Esc> cancels; gv reselects. Modeline TERMINAL-VISUAL. Snap-to-live-edge on Insert entry. Jump-list (<C-o> / <C-i>) restores the recorded scroll position when landing back on a Terminal entry (PositionEntry::terminal_scroll_offset).
  • T4 polish (4/5) (2026-05-25) — Resize wired in editor_actor::SetPaneViewport: on pane geometry change, SharedTerm::resize updates the alacritty grid + republishes the snapshot, and PtyHandle::resize triggers SIGWINCH so the child re-lays-out. <C-w>T (move-pane-to-new-tab) landed: new Action::MovePaneToNewTab + AppEffect::MovePaneToNewTab + do_move_pane_to_new_tab handler (closes pane in current tab when >1 leaves, then opens a fresh tab holding the same BufferId at the same cursor / scroll; no-op echo when the pane is the only one in its tab). :tabterminal [cmd] (alias :tabterm) registered as ex:tabterminalAppEffect::TerminalSpawnInNewTabdo_new_tab + TerminalSpawn(cmd). :bd! already kills the child via the existing TerminalBuffer::Drop impl (abort reader → close PTY master → SIGHUP).
  • Queued: T4.2 mouse passthrough (own slice — needs mouse-encoder + per-renderer event capture + the terminal.mouse-passthrough option); T5 plugin surface (Phase 7+); word-motions (w/b/e) in Terminal Visual; dedicated theme slots (ui.match-background, ui.visual-background) so renderers can distinguish match / selection backgrounds.

diff-system (in design, 2026-05-28)

Design fragment: ../architecture/diff-system.md. Synopsis in ../architecture/design.md §5.13. Covers inline overlay against a baseline, side-by-side two-way diff, three-pane three-way merge, hunk motions / transfer operators. Data layer is imara-diff Histogram on spawn_blocking, RCU-published via arc-swap; presentation is a DiffMap transform per pane. Two foundational primitives (displacing virtual rows, scroll-binding pane groups) are shared with multibuffer-views and post-v1 inlay hints.

  • D.0a (2026-05-28) — Displacing virtual-row primitive landed in lattice-cells. New virtual_rows module exports VirtualRow / AnchorPosition / VirtualRowMatrix / VirtualRowVersion / VirtualRowProvider / ProviderId. New DisplayRowEntry / DisplaySlice / DisplaySliceIter in matrix module + CellMatrix::display_slice(scroll, height, &VirtualRowMatrix) -> DisplaySlice<'_> method layered additively over the existing slice() — zero renderer-call-site change. Ordering contract: Above-at-line < Cell-at-line < Below-at-line; folded-line anchors emit at the next visible row; past-EOF anchors clamped + emitted at end. Vim motion semantics preserved by construction (motion_line_down / motion_line_up operate in source-line space; virtual rows are visual chrome, not navigation targets). 57 tests green (15 new D.0a + 42 pre-existing). Criterion bench virtual_row_layout + display_slice_iter recorded; CI gate enforcement deferred to first production consumer. Design fragment landed: ../architecture/virtual-rows.md; synopsis at design.md §5.15.

  • D.0a.1 (2026-05-29) — virtual_rows_worker landed on lattice-host. Sibling of cells_worker. New VirtualRowProviderRegistry (Mutex<HashMap<ProviderId, Arc<dyn VirtualRowProvider>>>) for consumer registration; register / unregister / snapshot / len. Worker holds VirtualRowsWorkerState { last_fingerprint: Option<u64>, next_publish_version: u64 } across recompute calls. Cache-hit short-circuits on the stable hash of [(provider_id, provider_version)] + source_line_count — so rebuilds happen only when something actually changed, even when publish_render_state wakes on every dispatch tick. Published matrix's version bumps monotonically only on actual content change; downstream consumers compare versions to invalidate caches. recompute(state, render_state, providers, matrix_cell) decision function (sync; tests call directly) returns Clear / CacheHit / Recomputed. Production run(...) async loop awaits VirtualRowsWake's Notify; fires paint_request.notify_one() on Clear or Recomputed (cache hits skip the paint wake). New pub virtual_rows_matrix_cell: Arc<ArcSwap<VirtualRowMatrix>>, pub virtual_rows_wake: VirtualRowsWake, and pub virtual_row_providers: Arc<VirtualRowProviderRegistry> fields on Editor (same Arc-sharing discipline as cells_matrix_cell so worker writes land in RenderState-visible cells). editor_boot spawns the worker alongside cells_worker. publish_render_state fires virtual_rows_wake.notify_one() after the cells wake — same permit-style coalescing. Extended VirtualRowProvider trait with fn version(&self) -> u64 (cheap, monotonic; signals "data changed since last poll" so the worker can short-circuit without paying for expensive collect() calls). 12 tests green: registry register/unregister/duplicates, fingerprint order-independence + source-line-count sensitivity + provider-version sensitivity, recompute no-document Clear, cache-hit after no-providers initial Recomputed, publishes rows from provider, cache-hit when static, re-publishes when provider version bumps, re-publishes when document line count changes, merges rows from multiple providers. 419 unit tests green in lattice-host overall after the slice landed (no regressions).

  • D.0b (2026-06-08) — :set scrollbind / scb typed BoolOption in lattice-config + OptionCache::scrollbind field + Editor::scrollbind_group_id: Option<PaneGroupId> + rebuild_scrollbind_group helper (walks pane-tree leaves, collects those with scrollbind=true, drops old group and mints a fresh IdentityRowMapper group when ≥ 2 panes are bound; no-ops below 2) + "scrollbind" arm in apply_option_cascade. 4 new tests: single-pane no-group, two-pane creates-group + propagation, toggle-off drops group, idempotent double-rebuild. Reusable by side-by-side diff (D.4), :windo, and any future scroll-coupled pane pair.

  • D.1 (2026-05-28) — lattice-diff crate landed. Public surface: Hunk / HunkKind / LineRange / HunkIndex / DiffAlgorithm (Histogram / Myers / MyersMinimal — Patience dropped, not in imara-diff's surface); compute_two_way(a, b, alg) and compute_three_way(base, local, remote, alg) returning HunkIndex; patch::apply_two_way(a, b, hunks) round-trip primitive for tests. Three-way merge does content-equality detection — when both sides touch the same base region, the merger compares the resulting local and remote slices and downgrades to a non-conflict if they match (caught by a property test: identical local==remote should never conflict). Tokenisation uses imara_diff::sources::lines_with_terminator so trailing- newline differences are preserved. 29 tests green: 23 unit + 5 proptest (256 cases each across all three algorithms + three-way invariants) + 1 doctest. Bench recorded (benches/recompute.rs); CI gate enforcement deferred to D.2 per design plan.

  • D.2.a (2026-05-28) — DiffSubsystem skeleton landed in lattice-host::diff::subsystem. Process-wide registry of Arc<DiffSession> keyed by BufferId, behind a std::sync::Mutex (mutation is buffer-open / buffer-close frequency, never per-frame). Each DiffSession carries algorithm: DiffAlgorithm + hunks: ArcSwap<HunkIndex> so consumers read lock-free. Public surface: register (idempotent on buffer_id), lookup, drop_session, iter_sessions, is_empty, len. DiffSession::current_hunks / publish(Arc<HunkIndex>) give the RCU read/write pair. Lifecycle decoupled from Arc holders — drop_session removes the entry but in-flight clones stay coherent. 11 tests green (registry + session). No compute yet — that lands D.2.b.

  • D.2.b (2026-05-28) — Compute path on spawn_blocking landed in lattice-host::diff::subsystem. New BaselineSource: Send + Sync + 'static trait with one impl (StaticBaseline wrapping an owned Rope — clones are cheap, it's Arc-shared chunks). Future GitBaseline (D.7), BufferBaseline (D.4), MergeBaseSource (D.6) plug into the same trait. DiffSession gains a monotonic next_revision: AtomicU64 allocator, gated publish via try_publish_if_newer (drops stale revisions on strict greater-than), and recompute_blocking(baseline, current) — the synchronous body called from the spawn_blocking closure. DiffSubsystem::schedule_recompute(buffer_id, Arc<dyn BaselineSource>, current) spawns the recompute on tokio's blocking pool and returns a JoinHandle<Option<Arc<HunkIndex>>>. Supersede policy: no abort — overlapping computes both run, the gate picks the highest revision. D.2.c's debounce will eliminate most redundant spawns before they hit the pool. 21 tests green (10 new D.2.b: baseline snapshot, revision monotonicity, identical-rope empty hunks, changed-rope hunk classification, 3-recompute revision sequence, stale-publish drop, equal-rev drop, unregistered-buffer None handle, tokio happy-path publish, tokio serial-pair monotonicity).

  • D.2.c (2026-05-29) — Routing + debounce + bus subscription landed in lattice-host::diff::subsystem. Final shape per the docs/dev/architecture/diff-system.md §3.4 redesign:

    • BufferTextProvider — single host seam over buffer storage. The subsystem touches no buffer registry directly; everything that needs a live rope reads through this trait. Future ephemeral / plugin-owned buffers plug in via the same interface.
    • CurrentSource trait mirroring BaselineSource; BufferBaseline + BufferCurrentSource concrete live-rope impls. Unsaved-buffer-vs-unsaved-buffer diff is first-class — both sides resolve through the provider at snapshot time, neither needs a filesystem path.
    • DiffDescriptor { baseline, current, watch: Vec<BufferId> } — explicit dependency declaration. Descriptor author knows which sources are buffer-backed; the subsystem treats watch as the routing key.
    • Centralized inverse watchers index: edits to buffer X fan out via one HashMap<BufferId, Vec<SessionKey>> lookup. Cost per edit is O(1 + dependents), independent of total session count — scales to project-wide diff / AI multi-file openDiff (N=100..1000 sessions) where the per-session-actor alternative would clone events N× per keystroke through the global event bus. See §3.4.4 for the scaling discussion and rejected alternatives (Helix direct-call, Zed per-entity-actor).
    • Per-session lazy Debouncer: zero tasks at rest. On first poke() after idle, spawns a tokio task that sleeps the debounce window, re-reads its epoch counter, and either reschedules or fires the runner and self-terminates. Default window: 50ms (DEFAULT_DEBOUNCE_WINDOW, matches Helix's diff debounce). Overridable via with_debounce_window.
    • DocumentBufferResolver trait for the protocol-layer DocumentId → host-layer BufferId translation that the bus events require. Host supplies the impl over BufferRegistry; tests inject mocks.
    • bind(bus, resolver) -> DiffSubscriptionGuard: subscribes once to DocumentChanged + DocumentClosed, spawns one drainer task that fans events through the routing path. The returned guard's Drop unsubscribes the bus subscription and aborts the drainer task — RAII lifecycle.
    • drop_session(buffer_id) now scrubs descriptor + every watchers bucket entry + the debouncer in addition to the session. Re-register with a smaller watch list scrubs stale entries before installing new ones.
    • note_buffer_edited / note_buffer_closed public routing entry points (tests + future non-bus drivers). 39 tests green (18 new D.2.c): buffer-source snapshot through provider (incl. empty-rope on missing buffer), descriptor + debouncer wiring on register_with_sources, multi-session shared-buffer fan-out, drop scrubs only the dropped session's entries, re-register with shrunken watch scrubs stale, debouncer single-poke fires after window, rapid-pokes coalesce to one runner, post-idle pokes fire twice (all three using #[tokio::test(start_paused = true)]
    • sleep-based auto-advance), note_buffer_edited triggers debounced recompute, no-op on unregistered, watched-baseline edit wakes session, note_buffer_closed drops, bus DocumentChanged routes through drainer → debouncer → recompute, bus DocumentClosed routes through drainer → drop_session, guard Drop unsubscribes + aborts drainer (post-drop bus event does not act on subsystem). Routing / bus tests use real tokio time because schedule_recompute goes through spawn_blocking which runs on the (real-time) blocking pool.
  • D.2.d (2026-05-29) — :describe-diff introspection. New DiffSessionDescription row type + DiffSubsystem::describe_sessions snapshot + build_describe_diff_content formatter on the subsystem (pure / synchronous; columns: BufferId, Algorithm, Rev, Hunks, Watches; sorted by BufferId for stable output; "No active diff sessions." when empty). Effect::DescribeDiff variant + ex:describe-diff ex-command registered in lattice-grammar (parse_no_args, Keyword surface, Display latency). "describe-diff" alias in lattice-host::excommand. Effect handler in dispatch.rs::handle_effect reads editor.diff_subsystem.build_describe_diff_content(), wraps in lattice_help::HelpContent::from_lines("describe-diff", lines).with_markdown_syntax(...), emits RendererSignal::DisplayBuffer(HelpList). New pub diff_subsystem: Arc<DiffSubsystem> field on Editor (initialised via Arc::<DiffSubsystem>::default() — the subsystem is Default and zero-cost to construct; live bus binding is wired by the first production consumer slice). 49 tests green (5 new D.2.d: empty-sessions output, sorted-listing across two sessions, reflects-published-hunks, column formatter, sources-less register omits watch). No "last recompute duration" column — that requires per-recompute telemetry not in the current DiffSession; defer to a follow-on slice once consumers ask for it.

  • D.2.e (2026-05-29) — Criterion bench landed at lattice-host/benches/diff_subsystem.rs with four workloads:

    • recompute_blocking at 1k / 5k / 50k lines — measured at ~469µs / ~7.86ms / ~634ms on dev hardware. The 5k case is right at the 8.3 ms one-frame keystroke ceiling at 120Hz, confirming the debounce in D.2.c is load-bearing (you can't recompute on every keystroke at v1 P95 file size).
    • routing_fanout at N=10 / 100 / 1000 sessions all watching the same shared buffer (worst case) — ~518ns / ~5µs / ~47.7µs. Grows linearly with actual dependent count, which is the expected per-poke cost.
    • routing_isolated_lookup at N=10 / 100 / 1000 sessions where only one watches the poked buffer — ~76ns flat regardless of N. Directly confirms diff-system.md §3.4's "cost per edit is O(1 + actual dependents), independent of total session count" claim. This is the production-typical case (project-wide diff over 1000 files but only one edited at a time).
    • debouncer_poke — ~17.7ns steady-state (atomic fetch_add + CAS; first-poke spawn cost amortised across criterion's iteration loop). CI gate enforcement still deferred until D.3 visual consumer lands and the paint pass joins the budget. Bench is annotated with the §3.4 paragraphs it backs so the design + bench stay coupled (heuristic #5).
  • D.3 — Inline overlay presentation. Carved during build into a series of sub-slices; D.3.a landed 2026-05-29 as the foundational wiring, follow-ons (D.3.a.1 / D.3.b / D.3.c / D.3.d / D.3.e) track the ex-command + content + motions + visual polish.

    • D.3.a (2026-05-29) — Foundational wiring landed in lattice-host. New Buffer::to_rope() -> Rope (lattice-core; Arc-share clone of the chunked rope, no deep copy); BufferRegistryTextProvider impl of BufferTextProvider over BufferRegistry::document_handle -> DocumentHandle::snapshot -> snapshot.buffer.to_rope; OnDiskBaseline { path } impl of BaselineSource that re-reads the file on every snapshot (with tracing::debug degradation to empty rope on I/O error — defensible all-Add presentation when the baseline file vanishes mid-session); DiffOverlayVirtualRowProvider impl of VirtualRowProvider in a new diff_overlay module. The provider walks the session's published HunkIndex, emits one Above-anchored empty VirtualRow per baseline line for each Remove / Change / Conflict hunk (Add hunks emit nothing — those lines are visible on the current side, future D.3.d / D.3.e renders the sign + tint). version() returns session.current_hunks().revision, so the virtual_rows_worker's fingerprint pass picks up hunk changes on the next wake. id() is 0xD1FF_0000_0000_0000 | buffer_id so the namespace is audit-visible. 10 new tests (8 provider + 2 OnDiskBaseline — file read + missing-file degradation). 429 lattice-host unit tests green overall.
    • D.3.a.1 (2026-05-29) — :diff / :diffoff ex-commands + end-to-end mounting. New DiffSession::publish_notify() returns a shared Arc<tokio::sync::Notify> fired on every successful publish (gated on try_publish_if_newer taking, or unconditional publish); the forwarder task spawned by :diff awaits it and pokes VirtualRowsWake so hunk republishes reach the worker without waiting for the next publish_render_state tick. BufferRegistry::buffer_id_for_document(doc_id) reverse lookup (O(N_documents); acceptable at v1 buffer counts). BufferRegistryDocumentResolver impl of DocumentBufferResolver bridges bus events. BufferRegistryTextProvider simplified to take BufferRegistry directly (it's already Clone + Arc<Mutex<...>> internally — no extra Arc wrapper). Editor gains diff_subscription_guard: Option<DiffSubscriptionGuard> + diff_forwarders: Arc<Mutex<HashMap<BufferId, JoinHandle<()>>>>. editor_boot constructs the DiffSubsystem, builds the resolver, calls bind(event_bus, resolver) under runtime_handle.enter(), and stows the guard on Editor. New Effect::DiffOpen + Effect::DiffOff variants in lattice-grammar; ex-command registrations ex:diff / ex:diffoff (parse_no_args, Keyword surface, Display latency); aliases diff / diffoff in lattice-host::excommand. Match arms updated in lattice-host effect_mutates + effect_mutates_or_yanks, plus lattice-ui-tui and lattice-ui-gpui dispatch no-op groupings. New Editor::do_diff_open reads the active document's path, builds a DiffDescriptor (OnDiskBaseline(path) + BufferCurrentSource(provider, buffer_id) + watch = [buffer_id]), calls register_with_sources, builds a DiffOverlayVirtualRowProvider, registers it with Editor::virtual_row_providers (clears any stale prior registration), spawns the publish-notify → wake forwarder, stores its JoinHandle in diff_forwarders keyed by buffer_id, and triggers an initial note_buffer_edited so the first recompute fires without waiting for the user's next edit. Editor::do_diff_off drops the session, unregisters the provider (looked up via the new free fn diff_overlay_provider_id(buffer_id)), aborts the forwarder, and pokes the virtual-rows wake so the worker immediately republishes a matrix without the dropped rows. Preconditions: :diff errors with E32: No file name for scratch buffers; reports "Diff session already open" if a session exists; :diffoff reports "No active diff session" when there isn't one. 431 tests green (2 new D.3.a.1: scratch-buffer error path, no-session-noop path).
    • D.3.b.1.gpui (2026-05-29) — GPUI mirror of the TUI interleaver. New EditorElement::virtual_rows: Arc<VirtualRowMatrix> field; window.rs paint_pane snapshots rs_guard.virtual_rows.matrix. GutterLineMeta gains is_virtual: bool; format_gutter_text returns a fully blank-padded string for virtual rows so column alignment stays the same. virtual_rows_at_gpui helper mirrors TUI's; push_virtual_row shapes the cells via text_system().shape_line and pushes placeholder entries into every parallel per-row array (shaped_text, shaped_gutter, row_meta with sentinel line_idx = u32::MAX, empty inlay_offsets, empty diag_segs, single (0, content_width, 0x3c_00_00) backdrop in overlay_quads_per_row). The prepaint gutter-driven walk interleaves Above-anchored rows → doc row → Below-anchored rows for each visible doc line. New doc_to_shaped_row_local: Vec<u32> tracks the shaped-text row index of each gutter entry; the cursor-row computation remaps through it so cursor positioning stays correct when virtual rows shift doc-row positions in shaped-text space. Sentinel line_idx = u32::MAX makes the cells-fast-path lookup in paint return None, falling through to ShapedLine::paint which renders the virtual row's shaped content over the backdrop quad. Existing GPUI test fixtures updated to populate is_virtual: false. 25 lattice-ui-gpui tests + 1461 workspace tests green.
    • D.3.b.1 (2026-05-29) — Virtual-row interleaver wired to TUI renderer. Closes the rendering gap Dhruva flagged during visual testing: DiffOverlayRefreshTask was populating the VirtualRowMatrix correctly, but the matrix had no path to the TUI renderer, so deletion blocks never appeared on screen. New RenderState::virtual_rows: Arc<VirtualRowsRenderState> substate exposes the matrix; build_render_state snapshots from editor.virtual_rows_matrix_cell.load_full() per publish. New virtual_rows_at(matrix, line, position) helper iterates anchored rows at a given line; new render_virtual_row(vrow, gutter_w, body_width) emits a ratatui Line with blank severity / diff-sign / gutter columns matching the document-row layout + a dim-red bg on the body (deletion-block convention from Vim's :diff
      • GitHub-style diffs). Active-pane for i in 0..height loop converted to while (out.len() as u32) < height that emits Above-anchored virtual rows → document row → Below-anchored virtual rows per visible doc line, stopping when viewport height is reached. Mirror refactor in the inactive-pane loop so cross-pane displays of the same document stay consistent. 1461 workspace tests green.
    • D.3.b.2 (2026-05-29) — Syntax highlighting inside deletion blocks. Provider runs a one-shot tree-sitter parse of the baseline rope via a new lattice_syntax::oneshot_highlight_lines(lang, registry, source, start_line, end_line) helper (wraps the existing Syntax::for_language_with_registry + parse + snapshot().highlight_lines chain without caching or actor plumbing). Populates Cell.fg per char in the rendered virtual rows. Renderers (TUI render_virtual_row, GPUI push_virtual_row) walk cells and coalesce adjacent same-fg runs into single Spans / TextRuns — typical baseline line goes from 80 spans to ~5. Backdrop colour stays hardcoded red from D.3.b.1; theme routing is D.3.b.3. Reason this needs its own slice rather than reusing the document's SyntaxHandle: baseline byte offsets diverge from current's under hunks, so the actor-published spans don't apply. Phantom-Document approach (Option C in §6.6.1) was rejected as too heavy for v1. Language detection uses the current document's Lang for the baseline parse — same file, same lang.
    • D.3.b.3 (2026-05-29) — Theme-routed diff colours. Adds DiffAdd, DiffChange, DiffRemove, DiffConflict entries to the theme. Replaces D.3.b.1's hardcoded Color::Rgb(60, 0, 0) and D.3.b.2's per-cell fg lookups with theme-resolved values. Blocked on theme expansion to add the four entries; the schema change probably wants its own slice in the theme series.
    • D.3.b (2026-05-29) — Deletion-block content from baseline snapshot. DiffOverlayVirtualRowProvider reshaped to hold a shared Arc<Mutex<DiffOverlayCache>> populated by the new DiffOverlayRefreshTask off the worker thread. The provider's collect() returns cache.rows.clone() (non-blocking — never reads baseline inside the worker's recompute path); version() folds session.current_hunks().revision ^ cache.cache_version so a cache install shows up in the worker's fingerprint pass even when the session revision is unchanged. New pure-sync render_rows(session, baseline) walks the session's HunkIndex, snapshots the baseline rope, materialises one VirtualRow per baseline-deleted line for Remove / Change / Conflict hunks, and encodes the line text into cells via Cell::with_codepoint per char (newlines / CRs stripped). Defensive: out-of-range baseline lines (truncation race) yield empty cells, not panics. :diff now spawns DiffOverlayRefreshTask (which subsumes D.3.a.1's standalone publish-notify forwarder — the task already awaits publish_notify between renders and fires virtual_rows_wake after each, so the wiring folds together). 434 tests green (3 new D.3.b: out-of-range baseline → empty cells, collect returns cached rows, version folds session revision + cache version).
    • D.3.c (2026-05-29) — Hunk navigation. New Effect::NextHunk + Effect::PrevHunk in lattice-grammar; ex:hunk-next / ex:hunk-prev ex-commands registered (Keyword surface, Display latency); aliases hunk-next / hunk-prev in lattice-host::excommand. New Editor::do_next_hunk / do_prev_hunk methods look up the active diff session, walk HunkIndex sorted by ranges[1].start (current side), find next / prev hunk strictly past / before the cursor's line, wrap on no-match. Surfaces info messages on no-session ("no diff session") or empty-hunks ("no hunks"). Match arms updated in lattice-host effect_mutates + effect_mutates_or_yanks, lattice-ui-tui dispatch (Effect → method dispatch + no-op groupings), and lattice-ui-gpui no-op grouping. Direct ]c / [c chord binding deferred — matches the precedent established by ]d (diagnostic jump) which is currently accessible via :diag-next but not yet bound to the ]d chord pending the 8.g normal-mode keymap audit slices. Both :hunk-next / :hunk-prev work today. Design fragment §6.1 expanded with the hunk-vs-file two-axis composition note: when a diff view is mounted on top of a multibuffer (project-wide A.1, AI multi-file A.2), ]c / [c walk hunks within the current excerpt while multibuffer-views.md §6.1's ]E / [E walk between files (= excerpt-source boundaries). No diff-system- specific file-navigation keybindings — the multibuffer "different-source" excerpt motions ARE the file-nav primitive in those contexts. Mirror notes added to multibuffer-views.md §A.1 (ProjectDiffProvider) + §A.2 (AIProposedEditsProvider) so future provider implementers don't re-litigate the composition. 5 new D.3.c tests (no-session messages on both motions, next walks forward + wraps to first, prev walks backward + wraps to last, empty-HunkIndex info message); 439 lattice-host unit tests green.
    • D.3.d — Gutter sign rendering. Carved during build into a data layer + per-renderer integration sub-slices.
      • D.3.d.0 (2026-05-29) — Data layer. New DiffSignKind { Add, Remove, Change } enum (Conflict collapsed into Change for v1; D.6 three-way merge refines). New DiffSignMap — sparse sorted Vec<(u32 line, DiffSignKind)> + revision tag. DiffSignMap::sign_at(line) is O(log n) binary search; renderer hot path. Pure compute_diff_sign_map(&HunkIndex) derives the map: Add → every current-side line gets Add; Change / Conflict → every current-side line gets Change; Remove → no sign (deletion already surfaced via D.3.b deletion blocks). New DiffSession::sign_map ArcSwap<DiffSignMap> field + sign_map() / publish_sign_map accessors. DiffOverlayRefreshTask now refreshes the sign map at the top of every run_once — published strictly before the deletion-block cache so a renderer reading both in the same paint pass sees consistent state (same revision on both). New Editor::diff_signs_for_active() -> Option<Arc<DiffSignMap>> helper for renderer integration. 8 new tests (sign-map derivation: empty, add, change, remove no-op, conflict = change, sorted entries; session round-trip: starts-empty, publish round-trips). 447 lattice-host unit tests green.
      • D.3.d.1 (2026-05-29) — TUI renderer integration. New DIFF_SIGN_GUTTER_WIDTH: u32 = 1 reserved unconditionally so the layout stays stable on :diff / :diffoff. New render_diff_sign_cell(view, line_idx) reads through RenderState::diff.sign_map — lock-free ArcSwap load; renderer hot path. Sign sits left of line numbers, between the LSP severity column and the gutter — matches editor convention across Vim signcolumn / Helix / Zed / VSCode / JetBrains. (Initial RIGHT-of-numbers / GitHub-PR placement was reverted in 72954f9 after Dhruva flagged convention priority for diff features — see saved memory feedback_convention_first.) Hardcoded colours (Add → green, Change → yellow, Remove → red); D.3.e will route through theme entries. Layout: [severity] [diff_sign] [fold + line_num] [body...]. Cursor-position arithmetic accounts for the new column. Three cursor-position tests updated. New RenderState::diff: Arc<DiffRenderState> substate so the renderer never holds an Editor reference; build_render_state snapshots editor.diff_signs_for_active() per publish. 163 lattice-ui-tui render tests + 447 lattice-host unit tests green.
      • D.3.d.2 (2026-05-29) — GPUI renderer integration. Same RenderState::diff.sign_map read path. New GutterLineMeta::diff_sign: Option<(char, u32)> field pre-resolved at window.rs paint_pane time from rs.diff.sign_map.sign_at(line_idx). format_gutter_text layout: {fold}{sev}{diff}{num:>width$} — diff sign LEFT of line number, matching TUI placement and the Vim/Helix/Zed/VSCode convention. build_gutter_runs emits 4 runs (fold, severity, diff, line-number + trail) so the diff sign gets its own colour. Three existing format tests updated for the new width; one new test (gutter_text_format_diff_sign_left_of_line_number) verifies placement. Text-based glyphs (+ / ~ / -) for parity with TUI; sprite atlas (§5.6.7) variant is a follow-on if/when GPUI demands it.
    • D.3.e (2026-05-29) — Line background tints. Reuses the DiffSignMap data layer from D.3.d.0 — same per-line classification source for signs and tints. TUI: new diff_tint_bg(view, line_idx) returns Color::Rgb(0,50,0) for Add lines, Color::Rgb(50,50,0) for Change lines, None for Remove (no current-side row to tint — the D.3.b deletion block is the visible surface). New apply_diff_tint(spans, bg) layers the bg over every body span, preserving each span's fg + modifiers so syntax highlighting still reads. Applied AFTER all other body overlays (whitespace, hlsearch, visual selection, diagnostics underline) so the tint sits BEHIND everything. Both active and inactive panes tint consistently. GPUI: new EditorElement::diff_tint_per_row: Vec<Option<u32>> field; pre-resolved in window.rs paint_pane parallel to gutter_meta so rel_row indexes both arrays. Tints paint as full-row quads pushed FIRST into overlay_quads_per_row — every cursor / search / selection overlay paints over them. Width = source-char count + inlay-virtual-text width; zero-width rows get a 1-column tint so the backdrop stays visible on empty lines. Hardcoded colours (0x00_32_00 Add, 0x32_32_00 Change) for v1; theme routing via DiffAdd / DiffChange / DiffRemove entries is a follow-on once the theme expansion slice lands. 1461 workspace tests green.
    • D.3.f.0 (2026-05-29) — FoldProvider trait + registry refactor. Substrate-only slice: ProviderKind and ProviderId join Fold / FoldMethod in lattice-core::folding; FoldMethod gains #[derive(Hash)] so it can key the registry's primary map. New lattice-host/src/fold_provider.rs ships FoldContext<'a> (buffer + path + syntax + lsp_folds + diff_hunks pre-loaded), the FoldProvider trait (id + kind + compute(&FoldContext)), and FoldRegistry with built-in Manual / Indent / Markdown / Syntax / Lsp primary providers wrapping today's compute_*_folds helpers (Syntax + Lsp keep their cascades — markdown/indent fallback, syntax cascade — inside the provider impls). New Editor field fold_registry: FoldRegistry (built-ins seeded via Default). Editor::recompute_folds reshaped to pre-resolve the context once, dispatch the primary by FoldMethod, iterate registered overlays, then run today's closed-state and zf-manual carry-over unchanged. Pre-refactor recompute_syntax_folds / recompute_lsp_folds Editor methods retired (their cascade logic moved into SyntaxPrimary / LspPrimary). Manual foldmethod still early-returns when no overlays are registered (preserves pre-refactor parity for :set foldmethod=syntax:set foldmethod=manual transitions). No behaviour change for the five built-in methods; 53 existing fold tests + 452 host tests green; 3 new registry tests (primary lookup, overlay add/remove round-trip, same-id replace). Design lands in fold-architecture.md with the slice plan at archive/fold-architecture.md; diff-system.md §6.5 still describes the consumer. 1461 workspace tests green (regression sweep end-to-end).
    • D.3.f.1 (2026-05-29) — HunkFoldProvider overlay landed. New lattice-host/src/diff/fold.rs houses the provider (ProviderId(100), ProviderKind::Overlay) + the hunk_fold_identity helper that namespaces hashes with the literal "diff:hunk" so closed-state survives publishes when the span is unchanged. compute() reads the active session's HunkIndex via FoldContext::diff_hunks (loaded by Editor::recompute_folds from DiffSubsystem::lookup(buffer_id).map(|s| s.current_hunks())); emits one Fold per non-empty current-side hunk range of length >= 2, skipping pure-Remove hunks (zero current side — the deletion surfaces as a virtual row) and single-line hunks (z* no-op). LineRange::end is exclusive in lattice-diff; Fold::end_line is inclusive in lattice-core; provider subtracts one. Always-on registration: FoldRegistry::with_builtins() pre-seeds the overlay so Editor::default() and the editor-boot path get identical composition. Eventual- consistency model for publish→fold freshness: hunks appear on the next recompute_folds() trigger (edit / mode change / buffer switch / foldmethod change); no new wake mechanism — matches how virtual rows compose today. Tests: 10 unit tests in diff_fold (no session empty, empty index empty, Add hunk → fold with inclusive end, Remove skipped, single-line skipped, Change + Conflict both foldable, identity stable across revs, identity distinguishes spans, provider id, malformed hunk → no panic); 3 dispatch integration tests (recompute_folds adds hunk fold after publish, drops it after drop_session, closed-state survives republish); 1 fold_provider test (with_builtins pre-seeds the overlay). Updated diff-system.md §6.5 to point at fold-architecture.md. 466 host tests + 1461 workspace tests green.
    • D.3.f.2 (2026-05-29) — fold-recompute bench landed. New crates/lattice-host/benches/fold_recompute.rs
      • [[bench]] fold_recompute in Cargo.toml. Three workloads: overlay_only_at_n_hunks (end-to-end Editor::recompute_folds at foldmethod=Manual with N published hunks — isolates the D.3.f.0 registry dispatch + D.3.f.1 overlay emission + carry-over cost), hunk_provider_compute_pure (isolated HunkFoldProvider::compute), fold_identity_hash (raw hasher cost). N covers 0 / 10 / 100 / 1000 hunks. Recorded baselines (per benchmarks.md §Folds-D.3.f.2): 100-hunk recompute is 3.1 µs (2677× under the 8.3 ms one-frame keystroke ceiling); pathological 1000-hunk recompute is 144 µs (57× under budget). Pure provider compute at 100 hunks is 1.3 µs; the ~15× integration overhead is the closed-state carry-over loop + post-merge sort, both O(prev + new). CI gate enforcement deferred until the bench lands on a runner with stable wall-clock or a visual regression motivates an absolute ceiling — the headroom is large enough that bench-on-PR catches regressions. Closes D.3.f.
  • D.4 — Side-by-side two-way: :diffsplit, :diffthis, :diffoff extension; pane-group scroll-binding with hunk-correspondent row mapping + filler rows. Per-pane buffer mutability addressed by buffer-pair keyed membership (see pane-groups.md). Carve in ../archive/pane-groups.md.

    • D.4.a (2026-05-29) — PaneGroup substrate landed. New PaneGroupId in lattice-core::ui::pane. New lattice-host/src/pane_group.rs holds PaneGroupMember { pane, buffer }, the RowMapper trait, IdentityRowMapper, OffsetRowMapper (test stub kept in production code for downstream-slice smoke tests), and PaneGroup { id, members, mapper }. New Editor::pane_groups: Vec<PaneGroup> field + add_pane_group / drop_pane_group / remove_pane_group_member methods. Conflict check at registration honours the buffer pair — duplicate active members are rejected, suspended memberships (pane showing a different buffer than registered) coexist freely. propagate_pane_group_scroll invoked at the publish_render_state tail: finds the group matching (active_pane.id, active_pane.buffer_id), walks other members, writes mapper.map_row(...) into their stashed PaneState.scroll only when the target pane's current buffer still matches the registered one (mismatch ⇒ skip; closed pane ⇒ skip). Empty groups auto-prune on remove_pane_group_member. 9 tests (4 in pane_group: identity mapper, offset mapper saturation, unique ids, index_of by pair; 5 dispatch integration: add/drop round-trip, duplicate rejection, 2-pane identity propagation, buffer-mismatch suspension, prune-on-empty). 475 host + 1461 workspace tests green.
    • D.4.b (2026-05-29) — HunkRowMapper landed. New lattice-host/src/diff/pane_group.rs. Constructed with Arc<DiffSession> + the baseline / current member indices into PaneGroup::members. RowMapper::map_row dispatches on the (from, to) pair: matches the baseline → current direction, the current → baseline direction, or falls back to identity for unfamiliar pairs (e.g. future three-pane compositions). Per-direction logic is a single cumulative-shift walk over the published HunkIndex: row before this hunk ⇒ apply accumulated shift; row inside this hunk ⇒ proportional mapping (offset * to_len / from_len, capped at to_len - 1) or collapse to to.start when either side is empty (Add / Remove); row past this hunk ⇒ accumulate current_len - baseline_len and continue. Past all hunks ⇒ apply final shift. Pure functions map_baseline_to_current / map_current_to_baseline exposed for direct unit testing without round-tripping through the trait. Defensive: malformed hunks (ranges.len() < 2) are skipped (no panic); unfamiliar member pairs return identity. 12 tests: empty index = identity both directions, single Add (baseline rows past shift, current rows inside collapse), single Remove symmetric, Change with proportional inside (both expand and compress), cumulative shift across multiple Adds, mixed Add/Remove cancels, malformed hunk skipped, unfamiliar member pair = identity, round-trip through published session. 487 host + 1461 workspace tests green.
    • D.4.c (2026-05-29) — FillerRowProvider landed. New lattice-host/src/diff/filler.rs. Side enum distinguishes the two providers a side-by-side session needs (one per pane). Pure-sync collect() walks the session's published HunkIndex and translates each hunk into zero or more blank VirtualRows on the shorter side via compute_filler_rows. Algorithm: |baseline_len - current_len| filler rows on the shorter side; anchor at range.start with Above when the shorter range is empty (Add ⇒ baseline, Remove ⇒ current), or range.end - 1 with Below when both sides have lines (Change with one shorter). No off-thread refresh task — the work is O(hunks) and trivial; pure current_hunks() read inside collect(). Provider id namespaced with side-distinct bits (0xD1FF_0001_* baseline, 0xD1FF_0002_* current) so both sides coexist with the inline overlay provider (0xD1FF_0000_*) in the global virtual-rows registry. Conflict hunks skipped (three-way axis — D.6 territory); malformed hunks (< 2 ranges) skipped defensively. 14 tests: empty index = no fillers, Add = baseline only, Remove = current only, Change baseline-longer = current fillers Below, Change current-longer = baseline fillers Below, Change equal = no fillers, multiple hunks accumulate independently per side, Conflict skipped, malformed skipped, provider ids distinct per side, no overlay-namespace collision, collect reads published hunks, version changes with session revision, version differs across sides at rev 0. 501 host + 1461 workspace tests green. Wiring caveat: the virtual- rows worker is currently single-document (Editor::virtual_rows_matrix_cell is one global matrix tied to the active document). For side-by-side rendering to show fillers on both panes simultaneously, the worker needs per-document matrices. That upgrade lands in D.4.d alongside the :diffsplit wiring.
    • D.4.d:diffsplit / :diffthis ex-commands. Carved into four sub-slices to keep blast radius bounded:
      • D.4.d.0 (2026-05-29) — Per-document cells- matrix registry surface. New Editor::cells_matrices: Arc<Mutex<HashMap<BufferId, Arc<ArcSwap<CellMatrix>>>>> field; boot seeds the active document's entry sharing its Arc identity with Editor::cells_matrix_cell so the existing single-writer hot path (cells_worker → field → RenderState.cells.matrix) stays bit-identical. New Editor::cells_matrix_for(buffer_id) is the single port into the registry: lazy-inserts an empty cell on first ask, idempotent on repeat asks, distinct buffers get distinct cells. No worker iteration yet; D.4.d.1 makes the worker rebuild per visible buffer. 2 tests in dispatch::tests (active-doc Arc- identity invariant; lazy-insert + idempotent + distinct-per-buffer). 503 host tests green.
      • D.4.d.1 (2026-05-29) — Cells worker iterates registry. Carved into three sub-slices during execution so the RenderState shape change, worker iteration, and renderer per-pane lookup each land green independently. d.0's Arc-identity invariant means the active pane's matrix cell stays the seeded registry entry throughout, so no user-visible regression at any boundary.
        • D.4.d.1.a (2026-05-29) — CellsRenderState carries per-pane build inputs. New PaneCellsInputs { pane_id, buffer_id, matrix, version, snapshot, syntax_handle, inlay_hints, folds, viewport_height, foldenable, last_edit }; new CellsRenderState::panes: Arc<[PaneCellsInputs]> populated by publish_render_state from pane_tree.leaves() (Document leaves only). Active vs non-active inputs sourced via two distinct flags: is_active_pane (leaf.id == active) routes the consumed last_edit_for_cells slot; is_active_buffer (buffer_id == active) routes live Editor::folds
          • self.document.snapshot() vs the buffer_locals / BufferRegistry::document_handle lookups. Each entry's matrix Arc comes from cells_matrix_for(buffer_id) (idempotent so two panes on the same buffer share the cell). build_active_inlay_hints refactored into build_inlay_hints_for_buffer(buffer_id, &snapshot) so non-active panes reuse the same gating + utf16 conversion. Worker still consumes the top-level active-doc inputs — no behavioural change yet; D.4.d.1.b makes the worker iterate panes. 4 tests in render_state::tests (single Document leaf → 1 entry; vsplit → 2 distinct pane_ids sharing buffer + registry Arc; non-Document leaves filtered; last_edit_for_cells routes only to the active pane and drains on next publish). 507 host tests green.
        • D.4.d.1.b (2026-05-29) — Cells worker iterates cells.panes. cells_worker::recompute no longer takes a top-level matrix_cell; it loops over the per-pane inputs, dispatches to the new recompute_pane(pane, theme, whitespace) -> WorkerDecision helper, and writes each pane's matrix into pane.matrix (the registry cell). Aggregate decision precedence is Recomputed > Incremental > Clear > CacheHit so the renderer's paint_request notify fires iff at least one pane produced content. try_incremental_build signature switched from (published, snapshot, &CellsRenderState) to (published, snapshot, &PaneCellsInputs, &Theme, &WhitespaceConfig) — same algorithm, fields sourced per pane. publish_render_state now sets cells.matrix = cells_matrix_for(active) instead of cloning cells_matrix_cell, so the renderer's existing top-level read lands on the registry's active-buffer cell (= the worker's write target) on both the boot path and the Editor::default() path. The cells_substate_is_populated_on_publish + cells_matrix_arc_identity_is_stable_across_publishes tests updated to assert via the registry. Worker run and the editor_boot spawn call drop the cells_matrix_cell parameter; the field stays on Editor as the boot-time seed but is no longer a worker write target. Bench (benches/cells_worker.rs) updated: rs_for takes a matrix_cell parameter and publishes through cells.panes[0]. 5 new tests in cells_worker::tests (two-panes-distinct-buffers-both-rebuild, per-pane-cache-hit-skips-unchanged-pane, two-panes-sharing-buffer-share-one-matrix-write, empty-panes-is-cache-hit, pane-without-snapshot-clears-its-matrix). 512 host tests green; bench compiles.
        • D.4.d.1.c (2026-05-29) — Per-pane matrix lookup on CellsRenderState. New pane_matrices: Arc<HashMap<PaneId, Arc<ArcSwap<CellMatrix>>>> field built once at publish from cells_panes. New CellsRenderState::matrix_for_pane(pane_id) typed helper. Non-Document leaves are absent (publisher already filters them out of panes). The active pane's lookup returns the same Arc as cells.matrix so the existing renderer top-level read path is identity-equivalent for single-pane flows. Renderer call-site cutover to consult matrix_for_pane(pane_id) lands with D.4.d.3 when :diffsplit + the inactive-Document render path actually need to draw two distinct buffers' matrices side-by-side; d.1.c ships the scaffolding so that cutover doesn't have to also reshape the substate. 3 new tests (pane_matrices mirrors panes; matrix_for_pane returns the matching cell + None for unknown ids; non-Document leaves skipped). 515 host tests green.
      • D.4.d.2 — Same surface for virtual rows. Mirror of D.4.d.0 + D.4.d.1 on virtual_rows_matrix_cell. Carved into four sub- slices for the same blast-radius bounding as D.4.d.1.
        • D.4.d.2.0 (2026-05-29) — Per-document virtual-rows-matrix registry surface. New Editor::virtual_rows_matrices: Arc<Mutex<HashMap<BufferId, Arc<ArcSwap<VirtualRowMatrix>>>>> field; boot seeds the active document's entry sharing Arc identity with Editor::virtual_rows_matrix_cell so the existing single-writer hot path (virtual_rows_worker → virtual_rows_matrix_cellRenderState.virtual_rows.matrix) stays bit-identical. New Editor::virtual_rows_matrix_for(buffer_id) is the single port into the registry: lazy-inserts an empty cell on first ask, idempotent on repeat asks, distinct buffers get distinct cells. No worker iteration yet — D.4.d.2.1.b makes the worker rebuild per visible buffer. 2 tests in dispatch::tests (active-doc Arc-identity invariant; lazy-insert + idempotent + distinct-per-buffer). 517 host tests green.
        • D.4.d.2.1.a (2026-05-29) — VirtualRowProviderRegistry keyed per BufferId. Internal storage flipped from HashMap<ProviderId, Arc<dyn VirtualRowProvider>> to HashMap<BufferId, HashMap<ProviderId, Arc<dyn VirtualRowProvider>>>. register / unregister / snapshot each take a BufferId scope arg; is_empty / len reflect totals across buffers. Empty inner scopes auto-prune on unregister so is_empty() keeps meaning "no providers anywhere." Worker's recompute now snapshots only the active document's buffer (rs.active_document.document_buffer_id), mirroring the per-pane iteration D.4.d.2.1.b will layer on. Today's sole producer — the D.3.a inline diff overlay registered in do_diff_open — registers against buffer_id, the same id the worker reads in single-pane flows, so behaviour is bit-identical for single-pane diff. Load- bearing for D.4.d.2.1.b: with the registry scoped per buffer, the worker can iterate visible panes and snapshot each pane's buffer independently; and baseline + current panes of a :diffsplit can each register their filler providers (D.4.c) without collisions even when ids happen to overlap. Diff-off (do_diff_off) likewise updates to pass buffer_id. 4 new tests in virtual_rows_worker::tests (registry_isolates_providers_by_buffer, registry_snapshot_scoped_to_buffer, registry_unregister_only_affects_its_buffer, recompute_only_polls_active_doc_providers). 521 host tests green; workspace build green.
        • D.4.d.2.1.b (2026-05-29) — Per-pane virtual-rows matrix on PaneCellsInputs. New field virtual_rows_matrix: Arc<ArcSwap<VirtualRowMatrix>> populated at publish in build_cells_panes (dispatch.rs) from self.virtual_rows_matrix_for(buffer_id). Active-pane entry shares Arc identity with Editor::virtual_rows_matrix_cell (D.4.d.2.0 boot seed), preserving the existing renderer read path through RenderState.virtual_rows.matrix until D.4.d.2.1.d swaps in a per-pane lookup. Two panes of the same buffer share the same virtual_rows_matrix Arc — the registry hands out one cell per buffer, not per pane, mirroring the cells side. No worker change yet: D.4.d.2.1.c switches virtual_rows_worker to iterate rs.cells.panes and write via pane.virtual_rows_matrix.store(...). Five non-test PaneCellsInputs construction sites updated (production publish + 4 test/bench constructors); they slot in ArcSwap::from_pointee(VirtualRowMatrix::empty()) defaults. 2 new tests in render_state::tests mirroring the D.4.d.1.a pattern (cells_panes_carry_virtual_rows_matrix_for_single_document_pane, cells_panes_share_virtual_rows_matrix_when_buffers_match). 523 host tests green; workspace build green. Mirror of D.4.d.1.a.
        • D.4.d.2.1.c (2026-05-30) — virtual_rows_worker iterates rs.cells.panes. recompute reshape: from a single global matrix_cell write into an iteration over every visible pane (rs.cells.panes), dispatching to a new recompute_pane helper that writes via pane.virtual_rows_matrix (D.4.d.2.1.b publish-time scaffold). Aggregate decision precedence is Recomputed > Clear > CacheHit (mirror of cells, minus the incremental path virtual rows don't have). Empty rs.cells.panesCacheHit (was Clear pre-cutover); pane with snapshot: None ⇒ that pane's matrix cleared, aggregate Clear. VirtualRowsWorkerState::last_fingerprint: Option<u64> becomes last_fingerprints: HashMap<BufferId, u64> so two visible buffers cache-hit independently; next_publish_version stays a single monotonic counter (per-matrix strictly-monotonic is what downstream consumers actually need). Worker run drops the matrix_cell parameter; editor_boot passes only (render_state, wake, providers, paint_request). The active-pane Arc-identity invariant (D.4.d.2.0 boot seed) means worker writes for the active pane still land on virtual_rows_matrix_cell, preserving the existing renderer read path through RenderState.virtual_rows.matrix until D.4.d.2.1.d swaps in a per-pane lookup. Existing 8 recompute tests adapted to the pane-driven shape via three new helpers (pane_inputs, rs_with_panes, snapshot_with_text + rs_with_single_pane). 4 new tests mirror cells D.4.d.1.b (recompute_empty_panes_is_cache_hit, recompute_pane_without_snapshot_clears_its_matrix, two_panes_distinct_buffers_both_rebuild, per_pane_cache_hit_skips_unchanged_pane, two_panes_sharing_buffer_share_one_matrix_write). 527 host tests green; workspace build green. Mirror of D.4.d.1.b.
        • D.4.d.2.1.d (2026-05-30) — Per-pane virtual-rows matrix lookup on VirtualRowsRenderState. New field pane_matrices: Arc<HashMap<PaneId, Arc<ArcSwap<VirtualRowMatrix>>>> built once at publish from cells_panes (derived from (pane_id, virtual_rows_matrix) pairs — the same shape cells_pane_matrices uses). New VirtualRowsRenderState::matrix_for_pane(pane_id) typed helper returns Option<&Arc<ArcSwap<...>>>. Non-Document leaves are absent (publisher already filters them out of cells.panes, and the lookup map is derived from there). The active pane's lookup returns the same Arc as virtual_rows_matrix_for(active_buffer_id) — which is the worker's write target — so any renderer that switches from rs.virtual_rows.matrix to rs.virtual_rows.matrix_for_pane(pane_id) for the active pane reads identical content. The existing top-level matrix field stays on VirtualRowsRenderState (active pane only) so renderers can migrate to the per-pane lookup incrementally. Closes the symmetric D.4.d.2 carve; D.4.d.3 (:diffsplit / :diffthis / :diffoff ex-commands) is the first user- visible slice. 3 new tests in render_state::tests mirroring D.4.d.1.c (virtual_rows_pane_matrices_mirror_panes_entries, virtual_rows_matrix_for_pane_returns_matching_cell_or_none, virtual_rows_pane_matrices_skip_non_document_leaves). 530 host tests green; workspace build green. Mirror of D.4.d.1.c.
      • D.4.d.3 (2026-05-30) — :diffthis / :diffsplit / :diffoff[!]. Carved into two sub-slices (both ✅).
        • D.4.d.3.a (2026-05-30) — :diffthis state machine + two-pane session- open + session-as-source-of-truth :diffoff. New Effect::Diffthis and reshape Effect::DiffOffEffect::DiffOff { force: bool } (forward-compat for D.6 three-way merge; v1 bang is operationally identical). New :ex:diffthis spec + alias; :ex:diffoff now accepts_bang: true. New Editor::pending_diffthis: Option<PaneGroupMember> (reuses PaneGroupMember because the staged pair becomes the first member of the new PaneGroup). New do_diffthis: stage on first call, complete-via-register_two_pane_diff on second call in a different pane, unstage on second call in the same pane. Rejects: non-Document active pane; active buffer already participates in a session; stale stages (pane closed / buffer swapped) silently re-stage. New register_two_pane_diff(baseline, current): builds DiffSession via register_with_sources with BufferBaseline + BufferCurrentSource (watch = [baseline, current]), constructs a PaneGroup with HunkRowMapper(session, 0, 1), binds the group id back onto the session via the new DiffSession::bind_pane_group, registers a FillerRowProvider per side scoped to that side's BufferId via the per-BufferId provider registry (D.4.d.2.1.a). Current side is the session's primary key. New DiffSubsystem::secondary_index: Mutex<HashMap<BufferId, BufferId>> + lookup_session_for(buffer_id) so either side of a two-pane diff resolves to the same session. register_with_sources / drop_session maintain the index via the new install_secondary_entries / scrub_secondary_entries helpers. New DiffSession::pane_group_id: Mutex<Option<PaneGroupId>> + bind_pane_group / pane_group_id()None for inline :diff sessions, Some(id) for two-pane sessions. Unified do_diff_off(force: bool): looks up the session via lookup_session_for, walks descriptor.watch as the source of truth for participants (the tab is not a grouping unit), unregisters every side's filler / overlay provider, drops the linked PaneGroup if any, drops the session, aborts any refresh task. v1 makes force a no-op; the bang lights up in D.6. Effect plumbing updated in lattice-host dispatch + lattice-ui-tui / lattice-ui-gpui effect-classifier match arms. 8 new tests in dispatch::tests (diffthis_stages_first_pane, diffthis_same_pane_twice_unstages, diffthis_second_pane_completes, diffthis_rejects_when_active_buffer_has_session, diffthis_rejects_non_document_active_pane, diff_off_from_baseline_pane_tears_down_two_pane_session, diff_off_with_force_bang_is_v1_identical_to_no_bang, subsystem_lookup_session_for_resolves_secondary_to_primary). 538 host tests green; workspace build green.
        • D.4.d.3.b (2026-05-30) — :diffsplit <file>. Composes vsplit + :edit <path> in the new pane + the register_two_pane_diff helper that backs :diffthis (D.4.d.3.a). New Effect::Diffsplit { path: PathBuf }, :ex:diffsplit spec with the new parse_required_path parser (empty arg errors at parse time), apply_diffsplit callback, ("diffsplit", "ex:diffsplit") alias, and the Effect::Diffsplit { .. } arm threaded through the effect-classifier match arms in lattice-host / lattice-ui-tui / lattice-ui-gpui. New Editor::do_diffsplit(path): gates the active pane on Document kind + the active buffer on "no existing diff session"; vsplits; set_active(new_idx); calls do_edit(Some(path), false); on success reads the now-active pane's (pane_id, buffer_id) as current and calls register_two_pane_diff(baseline, current); on open failure (Failed / Directory(_) / NoFileName) closes the new pane so the user isn't stranded in an empty vsplit. Cursor lands in the new pane (vim parity). 4 new tests in dispatch::tests (diffsplit_rejects_non_document_active_pane, diffsplit_rejects_when_active_buffer_has_session, diffsplit_opens_and_registers_two_pane_session — happy path via Editor::boot + a temp file, diffsplit_rolls_back_split_on_open_failure). Closes D.4.d.3; D.4.e (bench) is the only remaining D.4 slice. 542 host tests green; workspace build green.
    • D.4.e (2026-05-30) — Pane-group benches. New crates/lattice-host/benches/pane_group.rs + [[bench]] pane_group in Cargo.toml. Three workloads: pane_group_no_group (early-return floor cost paid by every publish when no group is registered), pane_group_identity_propagation (2-pane identity-mapper propagation per tick — exercises group walk + buffer- mismatch check + mapper call + stashed-scroll write), hunk_row_map_p99_us (HunkRowMapper::map_row against a DiffSession carrying 100 hunks distributed Add / Remove / Change to exercise the three per-hunk branches in the cumulative-shift walk). Each bench iter samples 8 representative rows so the call cost is amortised realistically. Smoke-verified via cargo bench --bench pane_group -- --test. Backs diff-system.md §7 diff_scroll_bind_p99_us keystroke-budget claim + paramount goal #1 (the propagation tail runs on every publish, so its no- group floor + identity-bound cost both must fit inside the budget). Closes D.4 (side-by-side two-way diff fully wired end-to-end).
  • K (2026-05-30) — Keymap architecture refresh closed. Established mode-scoped keymap layers as the architectural substrate for D.5's diff-mode chords and the broader emacs-style composability story (user- config bindings can target a specific mode; chords are scoped to that mode's lifecycle so the same chord can mean different things in different modes without collision). All four sub-slices landed: K.1.a (ActiveModes re-promotion semantics), K.1.b (MinorMode(ModeId) type rename), K.1.c (per-keystroke active-buffer mode filter via lookup_with_context), K.1.d (:describe-key runtime registry section with active/inactive annotation).

    • K.1.a (2026-05-30) — ActiveModes::push_minor moves-to-end on re-activation. Pre-K.1.a: push_minor was a no-op when the minor was already present, so the "later activation wins" claim in the active.rs doc-comment (and mode-architecture.md §6.2) didn't hold under re-activation — a mode re-activated after siblings stayed at its original position. Post-K.1.a: re-activation removes the prior entry and pushes to the end, re-asserting the mode as most-recent. Matches emacs's minor-mode-map-alist re-promotion semantics. Load-bearing for K.1.c (per-keystroke minor-mode merge in active-buffer reverse activation order). 1 new test in active::tests (push_minor_moves_to_end_on_reactivation); 79 lattice-mode lib tests green. Also fixed pre-existing test-build gap: lattice-mode's dev-dep tokio needed the time feature for the cascade-rollback tests at registry.rs:1436 to compile.
    • K.1.b (2026-05-30) — KeymapLayer::MinorMode(u32)KeymapLayer::MinorMode(ModeId). Layer identity is now the mode identity — re-pushing for the same mode_id is idempotent on the layer (replaces bindings rather than minting a sibling). Five-tier changes: (1) lattice_mode::ModeId derives PartialOrd, Ord so KeymapLayer can keep its derived Ord (used for the sorted layers vec). (2) KeymapLayer::MinorMode re-typed to carry ModeId. (3) PushLayerKind::MinorMode(ModeId) carries the mode id directly; push_layer short- circuits to bindings-replace on re-push for the same mode. (4) New pop_minor_mode_layer(mode_id) complements the new push signature; pop_layer(LayerId) continues to work for back-compat. (5) KeymapCapability::OwnedLayer { layer_id }{ mode_id: ModeId } matching emacs's (:map foo-mode-map …) shape. Per-layer label derives from the ModeId's interned string, so :describe-key provenance reads minor-mode:diff-mode directly. Production callers updated: dispatch.rs's sync_keymap_overlays (snippet + completion-popup pushes) plus the test helpers in lattice-ui-tui's input.rs / keymap_insert.rs. Two new mode-id helper functions (completion_popup_mode_id, active_snippet_mode_id) in keymap_insert.rs keep the layer-tag and the push-site mode-id in lockstep (a future drift would surface as :describe-key showing the wrong mode name). 8 keymap_registry test sites updated to use typed ModeIds. Note: the existing conflicting_plugins_resolve_via_layer_priority test now reflects ModeId-alphabetic ordering at the registry-merge level (plugin-b > plugin-a wins); K.1.c will replace this with per-buffer active-mode reverse-activation order. 542 host tests green; 79 lattice-mode tests green; workspace build green.
    • K.1.c (2026-05-30) — Per-keystroke minor-mode filter. Registry now keeps two wait-free caches: merged (always-on: Builtin + MajorMode + User + Buffer) and minor_mode_tries (per-ModeId minor- mode tries, indexed for per-tick lookup). All writes rebuild both. New KeymapHandle::lookup_with_context( mode, chords, active_modes) composes a per-tick trie: starts from always-on, overlays each ModeId in active_modes in the listed order so last-activated overlays last and wins. Empty active_modes is the fast path (uses the always-on cache directly, no allocation). Legacy lookup(mode, chords) is preserved as "all-registered-minor-modes-active, alphabetical order" to keep existing dispatch callers (completion-popup, active-snippet, all the tests) working unchanged — their mode lifecycle already gates at push/pop. D.5's do/dp chord dispatch will opt into lookup_with_context with the active buffer's active_modes[document_buffer_id].minors() so per-buffer gating kicks in for diff-mode without forcing a complete refactor of the dispatcher entry point. 3 new tests in keymap_registry::tests (lookup_with_context_empty_active_modes_skips_minor_modes, lookup_with_context_chord_reuse_across_modes, lookup_with_context_last_activated_wins). 545 host tests green (+3); workspace build green. Note on layer-priority shift: pre-K.1.c the priority order was Builtin < MajorMode < MinorMode(_) < User < Buffer. K.1.c's per-tick fold applies minor-mode layers on top of the always-on merge, which puts them above User and Buffer at lookup time. This is deliberate — mode-scoped chords (do in diff-mode, M-d in corfu-popupinfo-map) intentionally claim their chord while the mode is active. Users wanting to override a specific mode's binding use OwnedLayer { mode_id } to bind inside that mode's layer (where same-layer last-write-wins applies), matching emacs's (define-key foo-mode-map …) semantics. The pre-K.1.c order survives for legacy lookup callers.
    • K.1.d (2026-05-30) — :describe-key runtime registry section. KeymapHandle::enumerate_chord_bindings( mode, chords) walks every layer under the registry mutex (telemetry path, not hot) and returns every (layer, BoundCommand) pair where the chord terminates as Bound. build_describe_key_content appends a new "Runtime registry:" section grouping hits per BindingMode (normal / insert / visual / replace), with each MinorMode entry marked [active] or [inactive — mode not active on this buffer] based on active_modes[document_buffer_id]. Surfaces the visibility story the user demanded: ask :describe-key do, get an immediate answer to "why doesn't this fire here? oh, diff-mode isn't active on this buffer." Static catalog output (from crate::keymap::lookup) stays unchanged at the top of the response; the runtime section is appended below. Minimum scope for K.1.d v1: layer label + active annotation per binding, source. Richer formatting (full Active / Shadows section diagrams, per-mode headers like "Bound to: …") is deferred — the information is there, the prose layout can iterate. 1 new test in dispatch::tests (describe_key_runtime_registry_marks_inactive_minor_mode_bindings). 546 host tests green (+1); workspace build green. Closes K.1; D.5's diff-mode dispatch wires into the new substrate without further keymap-architecture work.
  • D.5 — Hunk transfer operators do / dp via CommandRegistry. After K.1: do/dp register in the diff-mode MinorMode(ModeId) layer, not globally. Carved into D.5.a (mode lifecycle), D.5.b (do), D.5.c (dp).

    • D.5.a (2026-05-30) — diff-mode lifecycle. The architectural contract: a buffer participates in any DiffSessiondiff-mode is active on that buffer. Single chokepoint inside the subsystem so every present and future :diff* ex-command (:diff <buf>, :diffthis, :diffsplit, future :Gdiff D.7) and the doc-close auto-drop (note_buffer_closed → drop_session) inherit the toggle without each call site repeating the activation logic. Wiring:

      1. Register the diff-mode minor mode in lattice-mode. Empty in v1 — the bit is what other layers consult; do/dp bindings land in D.5.b/c under the MinorMode(ModeId::new("diff-mode")) layer (K.1.b shape).
      2. Add participants: SmallVec<[BufferId; 3]> to DiffDescriptor. Conceptually distinct from watch: watch = "subscribe to edits on these ids", participants = "these ids are user-visible diff sides that should get the mode". In v1 they happen to coincide for buffer-backed sources and diverge only when a baseline source contributes no live buffer (file-on-disk inline) — so the inline path passes [primary], the two-pane path passes [baseline, primary], and D.7's :Gdiff will pass [primary] against a GitBaseline. D.6 three-way lands as [doc_a, doc_b, doc_c].
      3. New DiffModeBridge host service. Per-buffer refcount (HashMap<BufferId, SmallVec<session_keys>>): activation appends a session key to the buffer's bucket; deactivation removes it; bridge flips the ActiveModes.diff-mode bit off only when the bucket empties. The refcount handles the corner case where the same buffer participates in two sessions simultaneously (today: baseline-side buffer staged into a second session; D.6 future: MergeBase also diffed against working tree). Cheaper than tightening the rejection check in three different ex-commands.
      4. DiffSubsystem holds Option<Arc<DiffModeBridge>> injected at editor boot; register_with_sources activates each participant after the session lands in the registry; drop_session deactivates them before clearing the entry. Subsystem doesn't import lattice-mode — it sees the bridge as a trait, so the existing layering holds.
      5. Two simultaneous independent sessions (file1↔file2, file3↔file4) stay fully decoupled at every shared- state layer — subsystem registry keyed by primary BufferId, watch list bucketed per BufferId, pane-group (pane, buffer) conflict check (D.4.a), per-BufferId virtual-row provider registry (D.4.d.2.1.a), per-buffer ActiveModes. The MinorMode(ModeId) keymap layer itself is a single global table (K.1.b shape) — that's correct because the operator's implementation is shared while the operator's target session + peer buffer are resolved at execution time from lookup_session_for(active_buffer). Verified by a two-session independence test landing here.
      6. Scratch / unsaved buffers are full citizens: the diff layer takes no Path dependency (BaselineSource::snapshot → Rope and BufferTextProvider::buffer_rope(BufferId) → Rope are both path-free). :diff scratchA scratchB routes through register_two_pane_diff unchanged and gets diff-mode activated on both via the bridge. Test covers it here so the architectural commitment in §3.4.1 (already explicit about unsaved-vs-unsaved) is exercised end-to-end.
      7. Narrow-region diffs (multibuffer-views A.5, deferred): each :narrow mints a synthetic BufferId over a character-precise range; diffing two narrows is a two-pane diff with no diff-layer changes required. No test here (narrow not landed yet), but the lifecycle invariant in this slice is what makes the future composition free.

      Landed shape: the bridge lives on DiffSubsystem (not on Editor with a separate wiring step) because DiffSubsystem::default() is the only spot that can keep "subsystem owns a bridge" invariant correct under Editor::default()-style construction. The earlier draft used a DiffModeBridgeHook trait + Option slot

      • set_mode_bridge boot wiring — dropped during integration when tests showed Editor::default() would silently bypass the wire-up and leave the subsystem bridgeless. Subsystem-as-owner removes the failure mode entirely.

      562 host tests green (+16 new D.5.a tests: 9 bridge refcount + 7 dispatch integration covering inline, two-pane, :diffoff, doc-close auto-drop, two independent sessions, refcount-correct shared buffer, scratch-to-scratch). Workspace green. New files: crates/lattice-host/src/diff/mode.rs. Touched: crates/lattice-host/src/{diff_subsystem.rs, editor_boot.rs, dispatch.rs, lib.rs} + crates/lattice-host/benches/diff_subsystem.rs. Arch doc: docs/dev/architecture/diff-system.md §3.4.7 spells out the invariant.

  • D.5.b (2026-05-30) — do (diff-get) operator. The diff-mode do chord rewrites the current side's hunk to match the baseline; the edit cascades through the standard apply_edit_blocking → debounce → recompute pipeline so the hunk vanishes on the next publish. Wiring shape:

    1. AppEffect::DiffGet (lattice-grammar) + Action::DiffGet (lattice-host) join the unified command surface. ActionIds::diff_get is registered as action:diff-get via register_simple and listed in the verification table. Translation (AppEffect::DiffGet → Action::DiffGet) and dispatch (Action::DiffGet → editor.do_diff_get()) match the SnippetNext / OpenFoldAtCursor pattern exactly — no new dispatch machinery.
    2. crate::diff::mode::diff_mode_layer_bindings(actions) builds the { Normal: trie{ "do" → diff_get } } map. editor_boot pushes this once under PushLayerKind::MinorMode(diff-mode); K.1.c's per-keystroke active_minor_modes filter (already plumbed through RenderState → TranslateContext for this slice) handles the per-buffer gating so the chord is invisible on buffers without diff-mode active. Push-once-at-boot, not push/pop on activation — K.1.c is the per-buffer responsibility.
    3. DiffSubsystem::compute_get_edit(buffer_id, cursor_row) → Option<DiffGetPlan> is pure: look up the session and descriptor, search the published HunkIndex for the hunk whose current-side (ranges[1]) covers cursor_row (Add hunks: range non-empty, half-open coverage; Remove hunks: range empty, exact-anchor match — vim parity), read the baseline rope via descriptor.baseline.snapshot(), slice the baseline at ranges[0], return the (Edit, post_cursor_row) plan. Three-way Conflict hunks are skipped (D.6 lands those). Returns None cleanly when no session, no descriptor, no hunk, or cursor outside every hunk.
    4. Editor::do_diff_get() reads self.cursor.line for the active buffer, asks the subsystem for a plan, applies the edit via apply_edit_blocking, parks the cursor at plan.post_cursor_row. On apply_edit failure (e.g., undo-stack saturation): debug-log and skip the cursor reposition — never panic on the hot path.

    Active-minor-modes plumbing (precondition): the snapshot of the active buffer's ActiveModes.minors() is now threaded through RenderState.translator .active_minor_modes into TranslateContext and into lookup_normal{,_with_prefix}. This is what makes the K.1.c per-buffer chord gating real for Normal-mode bindings registered under MinorMode(_) layers. Touched: render_state.rs, input.rs, keymap_normal.rs, dispatch.rs, plus the UI mirror files (lattice-ui-tui::{app/dispatch, app/test_helpers, input, keymap_normal, runtime} and lattice-ui-gpui::lib).

    Tests (12 new): 9 pure compute_get_edit (Change, Add, Remove with anchor match + off-anchor miss, cursor outside hunks, three-way Conflict ignored, no session, no descriptor, first-match among many) + 3 dispatch integration (do_diff_get Change end-to-end through apply_edit_blocking with cursor reposition, no-session silent no-op, cursor-outside-hunks silent no-op). Per-buffer chord gating is already covered by the K.1.c lookup_with_context_* tests in keymap_registry.rs which use the diff-mode example directly. 574 host + workspace tests green (+12 new).

    Touched: crates/lattice-grammar/src/app_effect.rs, crates/lattice-host/src/{action.rs, actions.rs, diff/mode.rs, diff/subsystem.rs, dispatch.rs, editor_boot.rs}, crates/lattice-ui-tui/src/app/ dispatch.rs. Arch doc: design fragment §6.2 already describes do/dp semantics; no doc edit needed.

    Spec items deferred to D.5.c or later: undo composes correctly + macro replay produces identical edit are generic apply_edit_blocking properties already exercised by the existing edit-cluster test suite; recompute fires exactly once after the edit settles is verified at the subsystem level by D.2.b/c tests. No new integration test added here to avoid duplicating their coverage.

  • D.5.c (2026-05-30) — dp (diff-put) operator. Mirror of D.5.b: the diff-mode dp chord pushes the current side's hunk text into the peer buffer (two-pane sessions); inline file-on-disk baselines have no peer so the handler emits a clear error rather than silently no-op'ing. Wiring shape:

    1. AppEffect::DiffPut (lattice-grammar) + Action::DiffPut (lattice-host) + ActionIds::diff_put register as action:diff-put via register_simple and are listed in the verification table. Translate + dispatch routing matches the D.5.b shape exactly (AppEffect → Action → editor.do_diff_put).
    2. crate::diff::mode::diff_mode_layer_bindings is extended with the dp chord under the same MinorMode(diff-mode) layer as do. K.1.c per-buffer filter handles per-buffer gating (already-plumbed active_minor_modes from D.5.b).
    3. DiffSubsystem::compute_put_plan(buffer_id, cursor_row) → DiffPutOutcome returns a tri-state:
      • Edit { peer_buffer_id, edit, post_cursor_row }: two-pane (participants length 2 — peer = participants[0]). Reads the current side via descriptor.current.snapshot(), slices the current rope at ranges[1], builds an Edit that replaces the peer's ranges[0] with that slice.
      • NoPeerBuffer: participants length 1 — inline file-on-disk baseline (today's :diff) or D.7's git-baseline. No live peer to push into.
      • Nothing: no session / no descriptor / no hunk / three-way Conflict (D.6 owns :diffput <bufnr> with the disambiguating arg).
    4. Editor::do_diff_put matches on the outcome:
      • Edit → look up the peer's DocumentHandle via BufferRegistry::document_handle, block_on(handle.apply_edit(edit)), fan out a DocumentChanged event for the peer's document id + version (the peer's diff session shares the key with the current side via the indirection map; recomputes happen via the standard pipeline), park the cursor at post_cursor_row on the current side.
      • NoPeerBufferset_message(EchoLevel::Error, "dp: baseline is not a buffer; use :write").
      • Nothing → silent.
    5. Peer-side event fan-out is intentionally minimal: a single DocumentChanged publish to drive the diff recompute. LSP fan-in and syntax reparse for peer edits will follow the same path the active buffer uses today; the v1 cut is debounced diff recompute only.

    Tests (9 new): 7 pure compute_put_plan (Change pushes current into peer, Add inserts at peer anchor, Remove deletes peer range, inline single-participant returns NoPeerBuffer, no-session returns Nothing, cursor-outside returns Nothing, three-way Conflict skipped) + 2 dispatch integration: dp Change end-to-end through a real do_diffsplit-built two-pane session (manual publish overrides the auto-recompute via higher revision; peer buffer's content verified after; cursor parks at hunk start), inline single-participant session emits the "dp: baseline is not a buffer" error and leaves the current buffer unmutated. 583 host + workspace tests green (+9 new).

    Touched: crates/lattice-grammar/src/app_effect.rs, crates/lattice-host/src/{action.rs, actions.rs, diff/mode.rs, diff/subsystem.rs, dispatch.rs}, crates/lattice-ui-tui/src/app/dispatch.rs. Arch doc: design fragment §6.2 already describes do/dp semantics; no doc edit needed.

  • D.6 (2026-05-31, closed) — Three-way merge: conflict regions, :diffput <bufnr> / :diffget <bufnr>, :diff-accept / :diff-reject. Carved during build into eight sub-slices (D.6.a–h); see archive/diff-system.md for sequencing. Three-way merge UX shipped end-to-end: subsystem (D.6.a), pane-group + filler for 3 sides (D.6.b), :diffsplit <base> <remote> create (D.6.c), target-aware :diffput [<bufnr>] / :diffget [<bufnr>] (D.6.d), :diff-accept / :diff-reject + DiffOutcome completion signal for openDiff-style plugin flows (D.6.e), DiffConflict rendering (D.6.f), :diffoff! cascade tear-down across multi-session shared buffers (D.6.g), bench + integration covering the slice-plan canonical case + §11 risk gate (D.6.h). Per-sub-slice details below.

    • D.6.a (2026-05-30) — Three-pane subsystem lifecycle. New DiffDescriptor::remote: Option<Arc<dyn CurrentSource>> field + DiffDescriptor::is_three_way() accessor; None for two-way (the v1 default), Some for three-way. The recompute body switches engines on this field: DiffSession::recompute_blocking(baseline, current, remote: Option<&Rope>) dispatches to lattice_diff::compute_three_way(base, local, remote) when remote.is_some() and compute_two_way(baseline, current) otherwise. DiffSubsystem::schedule_recompute gains a matching remote: Option<Arc<dyn CurrentSource>> parameter and snapshots inside the spawn_blocking closure when present; DiffSubsystem::recompute_from_descriptor forwards descriptor.remote so the bus/debounce-driven recompute path inherits the three-way engine selection without any extra wiring. register_with_sources is already general over participants.len() + watch + the diff-mode bridge's per-buffer refcount (D.5.a) — a three-element descriptor activates diff-mode on all three participants and drops it cleanly when dropped; the secondary-index already maps every watched buffer back to the primary, so lookup_session_for resolves from any of the three sides without any code change. 8 new tests in diff::subsystem::tests (three_way_non_overlapping_changes_produce_no_conflict_hunks — engine integration produces 2 disjoint hunks with 3 ranges each; three_way_overlapping_changes_produce_conflict_hunk — overlap → HunkKind::Conflict surfaces through the session boundary; is_three_way_reflects_remote_presence — discriminator for D.6.c's do/dp dispatch; three_way_lookup_session_for_resolves_all_three_participants — secondary index handles 3-source descriptors uniformly; three_way_session_activates_and_deactivates_diff_mode_for_all_three — bridge refcount goes 0→1→0 on all three buffers; shared_buffer_across_two_way_and_three_way_keeps_diff_mode_until_last_close — mixed-shape refcount correctness; three_way_routing_publishes_three_way_hunks_on_edittokio::test end-to-end through note_buffer_edited → debounce → spawn_blocking → publish, asserts the published hunks carry 3 ranges). New test helper three_way_descriptor(provider, base, local, remote) parallels the existing two-way descriptor helper. 590 host + workspace tests green (+7 new; the eighth test was a pure-helper-coverage assertion folded into is_three_way_reflects_remote_presence). Touched: crates/lattice-host/src/diff/subsystem.rs, crates/lattice-host/src/dispatch.rs (descriptor literals add remote: None), crates/lattice-host/benches/diff_subsystem.rs (call-site signature update). Arch doc: design fragment §3.4.1 (DiffDescriptor) needs a one-line note about the remote field; landed alongside this slice.
    • D.6.b (2026-05-31) — HunkRowMapper + FillerRowProvider extended to three sides. Two surfaces:
      1. HunkRowMapper generalised from a two-index struct to one carrying a MapperShape { TwoWay | ThreeWay }. New constructor HunkRowMapper::three_pane(session, base_idx, local_idx, remote_idx) alongside the existing two-way new(session, baseline_idx, current_idx). Internal dispatch via MapperShape::pane_index_of(member_idx) -> Option<usize> resolves any PaneGroup::members index to its slot in Hunk::ranges (0/1 in two-way; 0/1/2 in three-way) or None for unfamiliar members. The cumulative-shift walk collapses to one pane-index-parametric body map_between(index, from_pane, to_pane, row); the D.4.b-shape free functions map_baseline_to_current / map_current_to_baseline become thin aliases for map_between(_, 0, 1, _) / map_between(_, 1, 0, _) (back-compat preserved for D.4.b callers — same observable behaviour on two-way hunks). Conflict hunks contribute to cumulative shift the same way Change hunks do — the conflict is about content, not row geometry. Member-pair dispatch on a 3-pane mapper handles all six directions (base↔local, base↔remote, local↔remote); same-member identity returns immediately; any unknown member returns identity rather than guessing.
      2. FillerRowProvider extended with Side::Remote enum variant + new Side::pane_index() accessor (0/1/2 for Baseline/Current/Remote). New namespace constant DIFF_FILLER_REMOTE_NAMESPACE = 0xD1FF_0003_0000_0000 keeps the remote-side provider id distinct from baseline (0xD1FF_0001_*), current (0xD1FF_0002_*), and overlay (0xD1FF_0000_*). compute_filler_rows rewritten to align all participating panes to max(lens) across the hunk's ranges: the longest pane gets zero fillers, each shorter pane gets max - this. Two-way semantics preserved (with 2 ranges, max(lens) = the other side's len, matching the D.4.c algorithm). Conflict handling is hunk-shape- aware: ranges.len() < 3 (two-way) skips Conflict defensively (compute_two_way doesn't emit it); ranges.len() >= 3 (three-way) emits fillers for Conflict the same way as Change. Cross-shape safety: a 2-way hunk queried for Side::Remote returns no fillers (slot absent), no panic. Provider-version salt extended (Remote = 2) so the worker's fingerprint distinguishes all three sides at revision 0.
      3. Scroll-binding 3-pane propagation rides free — D.4.a's propagate_pane_group_scroll already walks every group.members entry and calls mapper.map_row(active.member_idx, other.member_idx, active_scroll_row) for each peer. With the three-pane mapper installed, the same loop drives three-column scroll-bind correctly without any dispatch-side change. 17 new tests across two modules (10 in diff::pane_group::tests, 11 in diff::filler::tests — 17 total in D.6.b after deduplicating one cross-module helper):
      • map_between_is_pane_index_parametric_for_two_way — generic body produces identical results to the D.4.b-shape aliases.
      • three_way_change_hunk_maps_all_six_directions — all six directional shifts on a 3-3-4-6 change hunk.
      • three_way_conflict_hunk_contributes_to_shift_like_change — Conflict ≡ Change for row geometry.
      • three_way_add_on_one_side_collapses_inside_to_anchor — inside-hunk collapse when target side is empty.
      • three_pane_mapper_dispatches_all_six_member_pair_directions — non-trivial member indices (7/4/9) to verify identity-based lookup, plus same-member and unknown-member fall-through.
      • three_pane_mapper_with_two_way_hunks_is_still_safe — 3-pane mapper over a 2-way hunk: base↔local direction works; any direction involving remote is identity (slot absent).
      • Filler module: 3-way Change aligns to max, 3-way Add emits fillers on base + remote, 3-way Conflict emits fillers, equal-lengths zero fillers, 2-way-hunk-via-remote-side returns empty without panic, 3-way malformed single-range skipped, remote provider id distinct from baseline/current/overlay, version distinct across all three sides at rev 0, remote provider collect reads published 3-way hunks, Side::pane_index() matches ranges slots. 607 lattice-host lib tests (+17) + 1461 workspace tests + all other crates green; 0 failures across the whole workspace. Touched: crates/lattice-host/src/diff/pane_group.rs, crates/lattice-host/src/diff/filler.rs. Arch doc: design fragment §5.2 describes the algorithm at the "what" level (cumulative shift, filler = max - this); pane-index parametrisation is a "how" detail and stays in this ledger — no doc edit needed.
    • D.6.c (2026-05-31) — :diffsplit <base> [<remote>]. Extends the existing 1-arg :diffsplit parser to accept an optional 2nd path. One arg ⇒ two-way (D.4.d.3.b unchanged). Two args ⇒ three-way merge with current pane as local, arg1 = base (common ancestor), arg2 = remote. Effect::Diffsplit { path, remote: Option<PathBuf> }
      • Editor::do_diffsplit(path, remote) + new Editor::register_three_pane_diff(base, local, remote) dispatch helper that wires the 3-pane DiffDescriptor (D.6.a remote: Some(...)), HunkRowMapper::three_pane (D.6.b), and three FillerRowProviders (one per Side: Baseline/Current/ Remote per D.6.b). The grammar parser splits on whitespace, encodes both paths into a \x1f-joined Args::String (since Args has no Vec<String> variant in v1 and a single 2-arg ex-command wasn't worth the schema change), and the apply callback decodes back to Effect::Diffsplit. Three-arg or more rejected at parse time with "takes at most two paths." On 2nd-path open failure (do_edit returns Failed / Directory(_) / NoFileName) both new vsplits roll back so the user lands back on the original pane — mirroring the D.4.d.3.b two-way rollback discipline. Chained :diffthis N-way deferred to D.8 — initial D.6.c sketch widened pending_diffthis to Vec<PaneGroupMember> with a 3-stage state machine, but per user feedback that's the wrong model: vim's :diffthis toggles a per-buffer :set diff flag with any-N membership, not two-phase staging. The pending_diffthis field stays at Option<PaneGroupMember> with D.4.d.3.a's two-way- only semantics; D.8 (new top-level slice) refactors the descriptor to arity-agnostic + replaces staging with dynamic membership. 2 new tests in dispatch::tests (diffsplit_two_args_opens_three_way_session — full happy path through Editor::boot + temp files + descriptor + pane-group + per-side filler assertions; diffsplit_three_way_rolls_back_when_remote_open_fails — both vsplits collapse on remote-open failure, no leak). 12 :diffthis/:diffsplit tests green (+2 new); full workspace test run green. Touched: crates/lattice-grammar/src/{effect.rs, ex_commands.rs}, crates/lattice-host/src/{dispatch.rs, editor.rs}. Arch doc: design fragment §6.3 ex-commands table needs the optional <remote> arg on :diffsplit; landed alongside this slice.
    • D.6.d (2026-05-31) — :diffput [<bufnr>] / :diffget [<bufnr>] target-aware operators. DiffSubsystem::compute_get_edit / compute_put_plan gain an Option<BufferId> target argument; return types upgraded to rich tri/quad-state outcomes:
      • DiffGetOutcome::{Edit, TargetRequired, Nothing}
      • DiffPutOutcome::{Edit, NoPeerBuffer, TargetRequired, Nothing} TargetRequired carries available_targets: Vec<BufferId> so dispatch surfaces a clear "use :diffget (one of: …)" error when a three-way invocation arrives without disambiguation. peer_buffer_id renamed target_buffer_id to drop the two-pane-only connotation. The 2-way do / dp chord path preserves D.5.b/c semantics by passing target=Noneresolve_target_pane auto-resolves to the unique peer slot in 2-way; in 3-way it surfaces TargetRequired. Three-way Conflict hunks are resolvable when target is explicit (the allow_conflict = target.is_some() switch in find_covering_hunk); the chord path with no target stays defensively skipping Conflict (matches D.5.b/c). New free-function helpers in subsystem.rs: pane_index_of (active slot lookup, special-cases inline 1-participant sessions where the sole live buffer sits at slot 1 with no buffer-backed slot 0 baseline), resolve_target_pane (target slot resolution; checks descriptor.remote.is_some() for arity, not participants.len(), since inline has 2 slots but 1 participant), snapshot_for_pane (slot-0 → baseline, 1 → current, 2 → remote), find_covering_hunk (active-pane-parametric search). New grammar surface: Effect::DiffGetCmd { target: Option<u32> } + Effect::DiffPutCmd { target: Option<u32> } (u32 stays in the grammar to avoid a lattice-core dep; dispatch converts to BufferId). Ex-command parser parse_optional_bufnr validates non-negative-integer when present; apply callbacks apply_diffget / apply_diffput decode to the corresponding effects. :diffget / :diffput aliases added to the canonical-name table. Editor handlers do_diff_get(target: Option<BufferId>) / do_diff_put(target) match against the new outcome enums and surface TargetRequired as a user-facing error message naming the available targets. Chord callers (Action::DiffGet / Action::DiffPut) invoke with target=None; ex-command callers with target=Some(buf) (or None when bang-less). 8 new tests in diff::subsystem::tests: compute_get_edit_three_way_no_target_requires_one, compute_get_edit_three_way_with_target_resolves_conflict (the slice-plan ":diffput 2 resolves a conflict" case mirrored for the get direction), compute_get_edit_unknown_target_buffer_returns_nothing, compute_get_edit_two_way_explicit_target_matches_default (back-compat regression guard), compute_put_plan_three_way_no_target_requires_one, compute_put_plan_three_way_resolves_conflict_to_explicit_target (the canonical slice-plan test — :diffput 2 pushes pane-1's LOCAL into pane-2's range), compute_put_plan_two_way_no_target_targets_peer (back-compat), compute_put_plan_target_equal_to_active_returns_nothing (defence against :diffput <self-bufnr>). 617 host
        • 1461 workspace tests green. Touched: crates/lattice-grammar/src/{effect.rs, ex_commands.rs}, crates/lattice-host/src/{diff/subsystem.rs, dispatch.rs, excommand.rs}, crates/lattice-ui-tui/src/app/dispatch.rs, crates/lattice-ui-gpui/src/lib.rs. Arch doc §6.3 table for :diffput / :diffget accurately describes the target-aware behaviour; no edit needed.
      • D.6.e (2026-05-31) — :diff-accept / :diff-reject + completion-signal threading. New types in lattice-host::diff::subsystem:
        • pub enum DiffOutcome { Accept, Reject }, #[non_exhaustive] so the design-doc Partial variant can land later without breaking exhaustive pattern-match consumers.
        • DiffSession::completion: Mutex<Option<oneshot::Sender<DiffOutcome>>> with bind_completion(tx) + take_completion() API. Post-hoc binding — caller registers the session via the existing register_with_sources / register_two_pane_diff / register_three_pane_diff path, then binds the sender on the returned Arc<DiffSession>. Take is single-shot; re-bind overwrites and the prior receiver observes Closed. New ex-commands :diff-accept / :diff-reject + parallel Effect::DiffAccept / Effect::DiffReject variants. Aliases added to the canonical-name table (excommand.rs). Editor::do_diff_accept / do_diff_reject are API-callable directly by in-tree consumers (magit plugin, AI proposal flows, openDiff callers). The teardown path is shared: do_diff_off / do_diff_accept / do_diff_reject all delegate to a new private tear_down_active_diff_session(force, outcome, ok_message) helper. When outcome is Some and a sender is bound, the helper fires the signal before dropping the session (so the receiver observes the outcome, not a Closed error from the dropped session). When the receiver was already dropped, the let _ = tx.send(...) discards the Err — teardown still proceeds. Side-effect fix landed alongside D.6.e: the teardown helper now also unregisters Side::Remote filler providers (D.6.b) when the descriptor's is_three_way() returns true — prior do_diff_off leaked them for 3-pane sessions; latent bug, no user-visible regression since no production caller had landed a 3-way teardown path yet (D.6.c shipped the create, this is the close). 10 new tests:
        • 5 in diff::subsystem::tests: completion_take_is_single_shot, completion_rebind_drops_previous_sender (tokio::test — verifies the dropped sender Closes the previous receiver), completion_send_accept_routes_to_receiver (tokio::test — programmatic-consumer flow), completion_send_after_receiver_dropped_is_ignored (Err is non-fatal), unbound_completion_take_returns_none.
        • 5 in dispatch::tests: do_diff_accept_with_no_session_messages, do_diff_reject_with_no_session_messages, do_diff_accept_fires_accept_signal_and_tears_down (tokio::test — register session via :diffthis x2, bind sender, accept, assert receiver gets Accept + session torn down), do_diff_reject_fires_reject_signal_and_tears_down, do_diff_accept_without_bound_completion_still_tears_down (no signal needed; teardown unaffected). 627 host
          • 1461 workspace tests green (+10 new). Touched: crates/lattice-grammar/src/{effect.rs, ex_commands.rs}, crates/lattice-host/src/{diff/subsystem.rs, dispatch.rs, excommand.rs}, crates/lattice-ui-tui/src/app/dispatch.rs, crates/lattice-ui-gpui/src/lib.rs. Arch doc §3.2's DiffSession::completion: Option<oneshot::Sender<DiffOutcome>> sketch already matches what landed; no doc edit needed.
      • D.6.f (2026-05-31) — DiffConflict classification + conflict-region rendering. New DiffSignKind::Conflict variant (was previously collapsed into Change per D.3.d.0's "three-way will refine" comment). compute_diff_sign_map now routes HunkKind::Conflict to DiffSignKind::Conflict instead of Change — behavioural change for three-way sessions; two-way (D.3.d.0's only consumer pre-D.6) unaffected since compute_two_way never emits Conflict. Host theme (lattice-host::ui::theme::Theme) gains diff_conflict_sign_style: Style (the existing diff_conflict_line_bg: Color was already there as a reserved field). Defaults: bold magenta glyph, Color::Rgb(60, 0, 60) faint magenta line tint — distinct from the Add/Change/Remove green/yellow/red triad so users spot conflicts at a glance. Renderer surface — TUI (lattice-ui-tui): Theme::diff_conflict_sign_style propagated through the host_theme → tui_theme bridge; render_diff_sign_cell emits ? for Conflict; diff_tint_bg returns Some(diff_conflict_line_bg). Renderer surface — GPUI (lattice-ui-gpui::window): same two match arms (glyph + tint) extended with the Conflict variant reading from host_theme.diff_conflict_*. The deletion-block overlay path (overlay.rs:323) already handled HunkKind::Conflict since D.3, so Conflict hunks with non-empty baseline ranges show the deletion-block backdrop too — no change needed. 3 new tests in diff::overlay::tests: conflict_hunk_emits_conflict_signs (replaces the old conflict_hunk_emits_change_signs, which asserted the now-removed collapse-to-Change behaviour), conflict_signs_use_current_side_range (slot-1 decoration; base/remote slots not classified), mixed_change_and_conflict_keep_distinct_signs (regression guard against accidental re-collapsing). 629 host + 1461 workspace tests green. Touched: crates/lattice-host/src/{diff/overlay.rs, ui/theme.rs}, crates/lattice-ui-tui/src/{render.rs, theme.rs}, crates/lattice-ui-gpui/src/window.rs. Arch doc §3.1 already lists HunkKind::Conflict; §6.4 / §6.5 don't enumerate sign-kind glyphs (renderer-internal detail) so no doc edit needed.
      • D.6.g (2026-05-31) — :diffoff! cascade tear-down. The force parameter that D.4.d.3.a parked as a v1 no-op now means something:
        • :diffoff (no bang): tears down the single session resolved by DiffSubsystem::lookup_session_for(active) — the same secondary-index-driven lookup the rest of the diff code uses.
        • :diffoff! (bang): cascades teardown across every session the active buffer participates in, via the new DiffSubsystem::all_sessions_for(buffer_id) -> Vec<Arc<DiffSession>> method. Iterates the descriptors map and returns every session whose descriptor.watch contains buffer_id. The bang only differs from non-bang for the multi-session shared-buffer case (a buffer that participates in two simultaneous diff sessions — rare but architecturally supported via the refcounted diff-mode bridge, D.5.a). For typical single-session interactive use, the bang is operationally identical to the unbang. Teardown body refactored: extracted tear_down_single_diff_session(session, outcome) helper for per-session work, with tear_down_active_diff_session(force, outcome, ok_message) iterating over the resolved session set (one for unbang, all for bang). The virtual-rows-worker wake fires once after all sessions tear down (batched). Status message distinguishes the multi-session case: "{ok_message} ({count} sessions)" when force && count > 1, otherwise the plain ok_message. API surface for programmatic consumers: Editor::do_diff_off(force: bool) keeps its existing shape; DiffSubsystem::all_sessions_for is the new in-tree-plugin entry point for "find everything this buffer is in" (useful for cleanup flows that don't go through the active-pane convention). 6 new tests:
        • 3 in diff::subsystem::tests: all_sessions_for_unregistered_buffer_is_empty, all_sessions_for_single_session_returns_one (primary + watched-side lookups both work), all_sessions_for_shared_buffer_returns_every_session (the key case — shows lookup_session_for finds 1 while all_sessions_for finds 2).
        • 3 in dispatch::tests: do_diff_off_bang_with_single_session_matches_unbang (regression guard — bang on a 1-session buffer is identical to no-bang, plain "Diff closed" message), do_diff_off_bang_cascades_across_multiple_sessions (the canonical case — registers 2 sessions sharing one buffer via direct subsystem calls, verifies :diffoff! closes both and emits "(2 sessions)"), do_diff_off_unbang_on_shared_buffer_closes_only_one_session (the contrast case — without bang, only one closes; the other survives). 635 host + workspace tests green. Touched: crates/lattice-host/src/{diff/subsystem.rs, dispatch.rs}. Arch doc §6.3 ex-command table accurately describes :diffoff[!]; no doc edit needed.
      • D.6.h (2026-05-31) — Bench + integration tests close D.6. Bench: new bench_recompute_blocking_three_way in crates/lattice-host/benches/diff_subsystem.rs, mirror of the existing bench_recompute_blocking but exercising the compute_three_way path via recompute_blocking(_, _, Some(_)). Workloads: 1k / 5k / 50k lines, base + local + remote each mutated on disjoint stride-spaced rows so the engine output mixes non-conflict and conflict hunks. Design fragment §7's diff_recompute_p99_us budget (≤ 1000µs at v1 P95 5k lines) is for two-way; three-way is ~2× the work (two pairwise diffs against base) so the equivalent gate sits around 2000µs at 5k lines — bench just records the value; CI threshold gating is a later infra decision. Integration tests — 2 new:
        • three_way_rapid_edits_coalesce_to_one_recompute (subsystem.rs) — addresses §11 risk "multi-doc edit-event coordination". Fires three note_buffer_edited calls in rapid succession across the three watched buffers of a three-way session; asserts the resulting revision is 1 (exactly one recompute), not 3 (per-event). Load-bearing for the §3.4 debouncer-reset semantics — if the debouncer ever regresses to per-event spawn, this test catches it.
        • three_way_diffput_resolves_conflict_by_pushing_active_into_target (dispatch.rs) — the slice-plan canonical end-to-end case: :diffsplit base remote → manually publish a Conflict hunk → :diffput <remote-bufnr> → verify remote buffer's row mutated to local's content, local + base unmodified. Exercises the full chain from ex-command-equivalent dispatch through the target-aware compute_put_plan through apply_edit_blocking on the resolved peer's DocumentHandle. 637 host tests + workspace tests green; cargo bench --bench diff_subsystem -- --test smoke-passes the new bench. Touched: crates/lattice-host/benches/diff_subsystem.rs, crates/lattice-host/src/{diff/subsystem.rs, dispatch.rs}. D.6 closed. Arch doc: no edit needed — §7 budget claims and §11 risk both backed by the new artefacts.
      • D.6.i (2026-05-31) — VirtualRowKind discriminator + filler-row backdrop fix. The D.6.f GPUI parity audit surfaced an asymmetry in how diff-backdrop colours flow to the renderer (diff_deletion_block_bg as a scalar element field; diff_tint_per_row as a per-row Vec). The real bug it was masking: filler virtual rows (D.4.c / D.6.b side-by-side alignment) painted with the deletion-block red backdrop, mis-reading visual-padding rows as deleted content. Fix at the data layer: lattice_cells::VirtualRow gains a kind: VirtualRowKind field with variants { Generic, DeletionBlock, Filler } (#[derive(Default)]Generic for back-compat). Producers tag at construction:
        • lattice-host::diff::overlay (deletion blocks) → Kind::DeletionBlock
        • lattice-host::diff::filler (alignment padding) → Kind::Filler Both renderers updated in lockstep (per the saved feedback-tui-gpui-parity rule):
        • GPUI (editor_element::push_virtual_row): backdrop quad emitted only for DeletionBlock | Generic; Filler gets an empty quads vec.
        • TUI (render_virtual_row): bg becomes Option<Color>; Filler gets None and the pad-fill span paints with no backdrop. The original architectural framing ("unify diff line-tint propagation") resolved differently than drafted: rather than moving diff_deletion_block_bg into the per-row vector or vice versa, the right unification was per-VirtualRow-kind discrimination at the data layer. The element struct keeps both fields because they paint different things (document-row tints vs virtual-row backdrops); the asymmetry is now intentional and documented, not accidental. 2 new tests:
        • filler_rows_carry_filler_kind (diff::filler::tests) — D.6.b's filler-row provider must tag rows so the renderer skips the backdrop.
        • deletion_block_rows_carry_deletion_block_kind (diff::overlay::tests) — D.3 deletion-block overlay still emits backdrop-eligible rows. 639 lattice-host + workspace tests green. Touched: crates/lattice-cells/src/{lib.rs, matrix.rs, virtual_rows.rs}, crates/lattice-host/src/{diff/filler.rs, diff/overlay.rs, virtual_rows_worker.rs}, crates/lattice-ui-tui/src/render.rs, crates/lattice-ui-gpui/src/editor_element.rs. Arch doc: docs/dev/architecture/virtual-rows.md doesn't currently document the kind discriminator; one-line note added on next pass (no contract change — Default keeps every existing producer valid).
      • 📝 D.7 — Git baseline integration (:Gdiff) via a lattice-vcs crate over gix. Re-carved into VCS.1 (Layer 1 data crate) + VCS.2 (Layer 2 auto-inline-diff subsystem). Design: ../architecture/magit.md §3, slice plan: slice-plans/magit.md.

      • D.8:diffthis as buffer-group membership + arity-agnostic descriptor refactor. Design fragment: ../architecture/n-way-diff-membership.md. Replaces the D.4.d.3.a pending_diffthis: Option<PaneGroupMember> singleton with vim-style per-buffer :set diff toggle semantics on a singleton diffthis group (isolated from other sessions). Each :diffthis flips one buffer's membership in the diffthis group; the diff session is derived state with N participants (N=1 dormant, N=2 two-way, N=3 three-way, N≥4 returns a clear engine error). Carved into D.8.a–f during build. Companion refactor:

        • DiffDescriptor becomes arity-agnostic — drop the explicit baseline + current + remote slots, replace with sources: Vec<Arc<dyn DiffParticipantSource>>. The trait collapses today's BaselineSource + CurrentSource (both shape snapshot() -> Rope) into one. The descriptor / subsystem don't know the arity any more — that's session state.
        • lattice-diff exposes a unified compute_diff(participants: &[Rope], algorithm) -> HunkIndex. The engine internally dispatches to compute_two_way for N=2, compute_three_way for N=3, returns a typed error for N≥4 in v1. Hunks carry N ranges per Hunk::ranges.
        • Subsystem APIs: DiffSubsystem::add_participant( session_key, buffer_id) / remove_participant(session_key, buffer_id) to mutate group membership; auto-tear-down when N drops below 2.
        • :diffthis becomes a per-buffer toggle:
          • First call (no current group): create N=1 dormant session keyed under the active buffer.
          • Subsequent call on a fresh buffer: extend the group's participants.
          • Call on a buffer already in the group: shrink (= per-buffer :set nodiff).
        • :diffoff per-buffer semantics preserved; the session auto-collapses when membership falls below 2.
        • :diffsplit <file> / :diffsplit base remote keep their current shape but compose through the new arity-agnostic descriptor; internally call add_participant for each path opened, not the fixed-arity register_*_pane_diff helpers (those become thin wrappers).
        • Pane-group dynamic membership: add_member / remove_member on PaneGroup; HunkRowMapper consults session.participants().len() for arity at map-time rather than holding a fixed MapperShape.
        • Filler-row provider: registered/unregistered as members join/leave. Lands after D.6 closes — D.6's named scope is explicit three-way merge resolution UX, which :diffsplit base remote (D.6.c) already provides. D.8 generalises the model.
        • D.8.a (2026-05-31) — compute_diff engine entry + DiffEngineError. lattice-diff::compute_diff( sources: &[Rope], algorithm) -> Result<HunkIndex, DiffEngineError> becomes the only public engine entry — all external callers (lattice-host subsystem, both benches, lattice-diff's own tests/round_trip.rs integration tests) go through this single dispatch point. Existing compute_two_way / compute_three_way demoted to crate-private two_way / three_way helpers called only from inside compute_diff — no external consumer branches on arity. Internal helper compute_two_way_str renamed to two_way_str for consistency. DiffEngineError (with thiserror::Error derive) surfaces the two failure modes: Empty (N=0; never expected in production but defensively typed) and Unsupported { n: usize } (N≥4; v1's cap lives in the engine, not the API, so moving it later means replacing the Unsupported arm with computation — see n-way-diff-membership.md §12 for the cross-editor reference table that informs the cap-relocation timing). DiffSession::recompute_blocking in lattice-host::diff::subsystem keeps its existing (baseline, current, remote) signature for D.8.a (the signature change is D.8.c's scope); internally it now builds a Vec<Rope> from the args and routes through compute_diff instead of the fixed-arity helpers. The error path returns None (mirroring the existing stale-publish drop semantic) with a debug log — defensive against future N≥4 descriptors that D.6's fixed-arity shape can't produce today. Pre-v1 clean-break (no deprecation aliases): the workspace updates in lockstep — tests/round_trip.rs, benches/recompute.rs (lattice-diff), benches/diff_subsystem.rs (lattice-host), patch.rs internal-test module, and the subsystem all switch to the new entry in the same patch. 7 new tests in lattice_diff::compute::tests covering the dispatch matrix: zero-participants → Empty, one-participant → empty HunkIndex (dormant semantic per D.8.e), two-participants ≡ direct two_way output, three-participants ≡ direct three_way output (including Conflict on overlap), four-and-larger → Unsupported, error-message-text regression guard. lattice-diff test count: 24 → 31; lattice-host
          • workspace: unchanged at 639 + 1461 (D.8.a's surface is engine-internal, no host-level behavioural change). Both benches smoke-pass via cargo bench --bench {recompute,diff_subsystem} -- --test. Touched: crates/lattice-diff/{Cargo.toml, src/{lib.rs, compute.rs, patch.rs}, tests/round_trip.rs, benches/recompute.rs}, crates/lattice-host/{src/{lib.rs, diff/{mod.rs, subsystem.rs}}, benches/diff_subsystem.rs}. Arch doc: design fragment §4 names compute_diff as the only public entry; no further doc edit needed.
        • D.8.b (2026-05-31) — DiffParticipantSource trait + concrete source renames. The two-trait split (BaselineSource + CurrentSource) collapses into a single pub trait DiffParticipantSource: Send + Sync + 'static + Debug with the same snapshot() -> Rope contract. The original split was structural sugar — visual asymmetry at the descriptor's two named fields. With participants becoming an arity-agnostic Vec<...> in D.8.c, the slot index will carry the role; the trait split stops earning its weight even before that. Concrete source renames:
          • StaticBaseline → StaticSource
          • OnDiskBaseline → OnDiskSource
          • BufferBaseline + BufferCurrentSource → unified BufferSource (both had identical shape — provider + buffer_id — and differed only in which trait they implemented; the merge falls out of the trait collapse). Pre-v1 clean break — no deprecation aliases. A workspace-wide rename pass (word-boundary regex via Python) ordered carefully to avoid substring collisions (BufferCurrentSource renamed before CurrentSource). 5 files in crates/ touched directly by the rename pass; the trait-object type parameter updates in DiffDescriptor cascade through ~25 test-fixture + production reference sites. DiffDescriptor's field shape stays at baseline / current / remote for D.8.b — the sources: Vec<...> shape change is D.8.c's scope. Every reference through the Arc<dyn DiffParticipantSource> type now goes through the unified trait. No new tests — D.8.b is a structural rename; the existing 639 lattice-host + 31 lattice-diff + 1461 workspace tests pass unchanged. Both benches smoke-pass via cargo bench --bench {recompute,diff_subsystem} -- --test. Doc updates: diff-system.md §3.4.1 gains a "D.8 in progress" banner pointing at n-way-diff-membership.md — the §3.4.1 description of the D.2-era two-trait shape stays as historical record; the full §3.4.1 rewrite happens when D.8 closes at D.8.f. Historical references to the old names in doc-comment narrative (e.g., "D.8.b collapses the previous BaselineSource + CurrentSource") preserved on purpose so future readers can trace the rename. Touched: crates/lattice-host/src/diff/{subsystem.rs, overlay.rs}, crates/lattice-host/src/dispatch.rs, crates/lattice-host/benches/diff_subsystem.rs, crates/lattice-host/src/diff/mod.rs, docs/dev/architecture/diff-system.md.
        • D.8.c (2026-05-31) — DiffDescriptor shape change to arity-agnostic sources: Vec<Arc<dyn DiffParticipantSource>>. The D.6.a-era baseline + current + Option<remote> named-field triple collapses into a single ordered vector of participant sources. arity() accessor replaces is_three_way() (call sites switch to arity() == 3 where they need the three-way discriminator). DiffSession::recompute_blocking signature rewritten from (baseline: &Rope, current: &Rope, remote: Option<&Rope>) to (sources: &[Rope])compute_diff (D.8.a) is the engine entry it already routed to, but now the slice flows through cleanly without the intermediate fixed-arity decode. schedule_recompute similarly rewritten from (baseline, current_rope, remote) to (sources: Vec<Arc<...>>); snapshots all sources inside the spawn_blocking task so the descriptor's mutex isn't held across the snapshot calls. recompute_from_descriptor collapses to a one-line schedule_recompute(session_key, descriptor.sources) call. snapshot_for_pane (the D.6.d helper feeding compute_get_edit / compute_put_plan) becomes a one-liner over descriptor.sources.get(pane). TargetResolution::resolve_target_pane (D.6.d) branches on descriptor.arity() >= 3 instead of descriptor.remote.is_some(). Pre-v1 clean break, no aliases. Descriptor-literal rewrite: 25 sites across lattice-host/src/diff/subsystem.rs (10), lattice-host/src/dispatch.rs (12), and lattice-host/benches/diff_subsystem.rs (3). Done via a balanced-paren Python pass that walks each DiffDescriptor { block, parses out the baseline / current / remote field assignments (multi-line values supported), and splices in a sources: vec![...] line with the source expressions in slot order. Some test fixtures needed manual touch-up after the script (one site referenced a provider variable from a different fixture scope; reworked to use StaticSource inline). The existing is_three_way_reflects_remote_presence test renamed + rewritten as arity_reflects_participant_count (assert_eq against the explicit count rather than the boolean). 639 lattice-host + 31 lattice-diff + 1461 workspace tests green; both benches smoke-pass. Touched: crates/lattice-host/src/diff/subsystem.rs, crates/lattice-host/src/dispatch.rs, crates/lattice-host/benches/diff_subsystem.rs.
        • D.8.d (2026-05-31) — subsystem membership API. Four new public methods on DiffSubsystem, all returning Result<usize, MembershipError> (the new arity on success):
          • add_participant(session_key, source: Arc<dyn DiffParticipantSource>, participant_buffer: Option<BufferId>) — appends to sources and (when Some) to watch + participants + the inverse watcher index. Bridge gains a refcount on the new buffer. Rejects N≥4 atomically before mutation via MembershipError::EngineRejected( DiffEngineError::Unsupported{n}).
          • remove_participant(session_key, slot: usize) — slot-indexed remove. Auto-collapses: arity→1 keeps the session dormant (registered, bridge refcount stays), arity→0 auto-drops the session via drop_session.
          • remove_participant_buffer(session_key, buffer_id) — convenience that looks up the slot via [pane_index_of] then delegates. Updates watch + participants + bridge in this path (the slot-only remove_participant can't know which buffer left without a re-scan). Returns NotParticipant(buffer_id) if the buffer isn't in the descriptor.
          • replace_descriptor(session_key, descriptor) — atomic swap. Preserves the Arc<DiffSession> so every holder (renderer-side current_hunks readers, compute_get_edit / compute_put_plan callers, the completion-signal receiver) keeps its handle stable. The mode-bridge re-scrubs + re-installs participants the same way note_session_opened already does on re-open. MembershipError enum (with thiserror::Error): NoSession, SlotOutOfRange { slot, arity }, NotParticipant(BufferId), EngineRejected(DiffEngineError) (the #[from] DiffEngineError impl forwards typed engine errors — currently just Unsupported { n } when arity would exceed v1's N=3 cap). DiffModeBridge gains note_session_extended(session_key, buf) + note_session_shrunk(session_key, buf) — refcount increment/decrement on participant join/leave with the same idempotent guard as note_session_opened. DiffParticipantSource trait extended with buffer_id() -> Option<BufferId> (default None, overridden by BufferSource to return Some(self.buffer_id)). Load-bearing for the pane_index_of rewrite — D.8.d retires the D.6.d "inline-session special-case" (which assumed participants.len() == 1 means the buffer sits at slot 1 of Hunk::ranges). With the trait method in place, pane_index_of walks descriptor.sources.iter().position(|s| s.buffer_id() == Some(buf)) — correct for every shape including inline 1-source, inline 2-source-with-StaticSource-baseline, and arbitrary multi-pane. 11 new tests in diff::subsystem::tests: arity-grow 2→3 + descriptor mutations, fourth-participant atomic rejection, NoSession error path, remove_participant_buffer shrink 3→2 / 2→1 dormant / 1→0 auto-drop, NotParticipant error path, bridge refcount inc on add + dec on remove, replace_descriptor identity-preservation
          • N≥4 atomic rejection. 650 lattice-host (+11) + 1461 workspace tests green. Touched: crates/lattice-host/src/diff/{mode.rs, subsystem.rs}.
        • D.8.e (2026-05-31) — :diffthis rewrite to the singleton diffthis-group model. Retires the D.4.d.3.a "two-step stage → complete" state machine; replaces pub pending_diffthis: Option<PaneGroupMember> with two fields on Editor:
          • pub diffthis_group: Option<BufferId> — the session key for the singleton diffthis group (None ⇒ no group; Some(b) ⇒ session keyed under b, which is always the first buffer to enter the group).
          • pub diffthis_members: Vec<PaneGroupMember> — ordered members; slot index in pane_groups[diffthis_group].members matches. do_diffthis becomes a per-buffer toggle:
          • If the active buffer is in diffthis_membersdiffthis_toggle_off: calls remove_participant_buffer; auto-drops when arity falls to 0; rebuilds pane group + fillers otherwise (drop-and-recreate, with the removed buffer's filler scrubbed explicitly before rebuild to avoid orphan providers since rebuild_* iterates the post-retain members).
          • Else (active buffer is not in the group) → diffthis_toggle_on: creates a dormant N=1 session keyed under the active buffer when diffthis_group is None (no pane group, no fillers — the engine accepts N=1 as empty hunks per D.8.a); otherwise extends the group via add_participant, surfacing the engine-cap reject ("v1 supports up to 3 participants") atomically. rebuild_diffthis_pane_group_and_fillers(group_key) is the single arity-transition helper: drops any existing pane group + scrubs known fillers, then if arity ≥ 2 installs a fresh pane group with a HunkRowMapper (2-pane at arity 2, HunkRowMapper::three_pane at arity 3) and re-registers per-slot fillers under each member's BufferId. Bind / unbind via session.bind_pane_group(pg_id) keeps session identity stable across rebuilds. Pre-existing do_diffthis tests retired — they asserted the old "second invocation completes" semantic that D.8.e replaces. Six rewritten + new tests: diffthis_first_call_creates_dormant_singleton_session, diffthis_same_pane_twice_removes_and_auto_drops, diffthis_second_pane_extends_to_arity_two, diffthis_third_pane_extends_to_arity_three, diffthis_fourth_pane_rejected_at_engine_cap, diffthis_toggle_off_from_arity_three_shrinks_to_two. Two existing tests rebased onto the new session primary-key (now the first :diffthis buffer, previously the second): diff_off_from_baseline_pane_tears_down_two_pane_session, doc_close_auto_drop_clears_diff_mode_on_peer. 653 lattice-host tests (+3 net) green; workspace build clean. Touched: crates/lattice-host/src/editor.rs, crates/lattice-host/src/dispatch.rs.
        • D.8.f (2026-05-31) — :diffoff per-buffer semantic + pane-group helper collapse + final D.8 cleanup. Closes the D.8 series. Helper collapse. The D.4.d.3.a register_two_pane_diff and D.6.c register_three_pane_diff collapse into one register_pane_group_diff(members: Vec<PaneGroupMember>). Validates arity ∈ [2, 3] (arity 1 is :diffthis's job; arity 4+ is engine-rejected), pairwise-distinct members, and that no member already participates in a session. Builds the descriptor's sources from BufferSource per member, walks slot 0..N for filler-provider registration. Member layout convention preserved ([baseline, current] at arity 2; [base, local, remote] at arity 3), so call-site semantics are unchanged. Both :diffsplit <file> and :diffsplit <base> <remote> route through the new helper. :diffoff per-buffer. When the active buffer is in the singleton diffthis group, do_diff_off(false) now routes through diffthis_toggle_off (D.8.e's membership-API shrink): arity 2 → 1 (dormant; pane group + fillers torn down, session lingers under the surviving primary, diffthis_group stays Some); arity 3 → 2 (pane group + fillers drop-and-recreate at the smaller arity); arity 1 → 0 auto-drops the session. Vim parity: :set nodiff on one window doesn't un-set diff on the others. Non- diffthis sessions (inline :diff <buf>, :diffsplit) keep the existing full-session collapse for :diffoff — those shapes are user-explicit fixed-arity sessions; a uniform per-buffer shrink for them is a future generalization. :diffoff! (force, D.6.g cascade) preserved — bypasses the diffthis- aware branch and tears down every session the active buffer participates in. Tests retired (asserted v1's pre-D.8 full-collapse-on-:diffoff semantic): diff_off_from_baseline_pane_tears_down_two_pane_session, diff_off_with_force_bang_is_v1_identical_to_no_bang, diff_off_clears_diff_mode_on_both_panes. Tests added (D.8.f post-shrink shape): diffoff_from_diffthis_member_shrinks_to_arity_one_dormant, diffoff_bang_from_diffthis_member_full_teardown, diffoff_clears_diff_mode_only_on_removed_member. 653 lattice-host (= pre-D.8.f net) + workspace build clean. Touched: crates/lattice-host/src/dispatch.rs.
      • Slice sequencing: D.0 / D.1 in parallel; D.2 after D.1; D.3 after D.0 (a) + D.2; D.4 after D.0 (b) + D.3; D.5 after D.2; D.6 after D.4; D.7 after D.3. Open questions §8 (engine algorithm, conflict semantics, fold-of-equal, doc-close behaviour) resolved per slice.

        D.7 re-carved 2026-07-25: the VCS + Magit work splits into lattice-vcs Layer 1 (pure data), lattice-vcs Layer 2 (auto-inline-diff subsystem in host), and lattice-magit Layer 3 (feature-buffer modes, inverted out). Design: ../architecture/magit.md. Slice plan: slice-plans/magit.md.

        vcs-and-magit (in design, 2026-07-25)

        Design fragment: ../architecture/magit.md. Supersedes the 2026-05-31 sketch at ../archive/vcs-and-magit.md.

        Three layers: (1) lattice-vcs pure data crate over gixRepository, GitBlob, Reference, WorkingTree, Index, Commit, Branch, Stash, PathStatus. Read + Write API complete. GitBaseline as DiffParticipantSource. Zero lattice-* deps. (2) lattice-host::vcs subsystem — RepositoryWatcher, RepositoryEvent, auto-register DiffSession(GitBaseline(HEAD)) on DocumentOpened, git.auto-head-diff option. (3) lattice-magit crate — eight major modes (magit-status, magit-log, magit-commit, magit-blame, magit-diff, magit-stash, magit-branch, magit-rebase) + one shared minor mode (magit-core). Installs through SubsystemBoot seam. Inverted out of lattice-host (same tier as oil/file-tree).

        VCS Layer 1 — lattice-vcs data crate

        • VCS.1lattice-vcs crate (2026-07-25). Repository::discover, GitBlob::read, Reference::resolve, WorkingTree:: path_status + statuses, Index::stage_path / unstage_* / apply_patch (MG.18a; replaced the stage_hunk / unstage_hunk stubs), Commit::create / amend, Branch::checkout / create / delete, Stash operations. gix dependency isolated; zero lattice-* deps. 20 integration tests in temp git repos. GitBaseline deferred to VCS.2 (needs lattice-diff trait).

        VCS Layer 2 — auto-inline-diff subsystem

        • VCS.2 — Host-side VCS subsystem (2026-07-25). VcsSubsystem in crates/lattice-host/src/vcs/. Subscribes to DocumentOpened / DocumentClosed via event bus drainer; auto-registers DiffSession(GitBaseline(HEAD, path)) for files inside a discovered git repo; tears down on DocumentClosed. GitBaseline impl DiffParticipantSource via git show <rev>:<path> on spawn_blocking. git.auto-head-diff typed option (default true). VcsSubscriptionGuard Drop cleans up subscription + abort drainer. 714 existing host tests pass green. RepositoryWatcher deferred to VCS-future (notify watcher on .git/lattice-vcs already provides WorkingTree::statuses; external-change poke of diff sessions is layered on top of the auto-registration seam post-MG.1).

        Picker extension — transient mode

        • PICK.1 — Picker transient-mode extension (2026-07-25). TransientSpec / TransientGroup / TransientItem / TransientItemKind / TransientState / TransientValue types in lattice-picker::transient. Picker struct extended with transient, transient_state, transient_stack fields. Editor::open_transient, do_transient_trigger, do_transient_toggle_flag, close_transient methods. Input routing via PickerAppend/PickerBackspace dispatch arms checking transient.is_some(). TransientItemKind::Action(CommandId) handler invocation deferred to MG.8 (magit transients). Argument minibuffer-prompt flow deferred. 714 existing host tests + 62 picker tests pass green. Design: ../architecture/magit.md §8.1–8.3.

        Magit Layer 3 — lattice-magit feature-buffer crate

        • MG.1 — Crate scaffolding (2026-07-25). MagitCoreMode (minor mode, ActivationPolicy::Majors, navigation + close keymap), MagitStatusMode (major mode, status keymap stubs, on_activate creates empty buffer), install(boot) via SubsystemBoot, :magit-status via Effect::OpenSyntheticBuffer. Zero Editor::do_magit_* methods. 714 host tests pass green.
        • MG.2 — magit-status buffer rendering (2026-07-25). SectionIndex / Section / SectionEntry / SectionKind. refresh::refresh_status runs WorkingTree::statuses + Stash::list + git log --oneline on spawn_blocking, formats through SectionIndex::format_buffer, applies to buffer. on_activate discovers repo from . and spawns refresh. DiffCache + = toggle deferred to MG.3 (hunk actions need the diff-expansion path anyway). 714 host tests pass green. Fold provider added (2026-07-26): the "register fold ranges via fold overlay service" deliverable was never actually implemented — *magit:status* registered zero FoldSource overlays, so folding a file didn't fold its inline diff, and hunks/diffs weren't nested (TAB was also structurally dead: magit-core's handler unconditionally returned None before the generic fold-toggle path could run). Added MagitStatusFoldSource (fold_source.rs), mirroring lattice_diff::HunkFoldSource's live-recompute shape: emits a nested Fold per expanded entry (header line through its patch) containing one Fold per @@ hunk, using the exact inserted-line count from StatusBufferState::expanded (see MG.3) rather than a re-scanned guess — sidesteps the same text-parsing ambiguity MG.3's fix addresses. magit-core's TAB/S-TAB handlers now return Effect::AppAction(ToggleFoldAtCursor) / CycleFoldsGlobal; the redundant <Tab> → toggle-diff binding in magit-status-mode's own keymap (duplicate of =, shadowing magit-core's fold binding) was removed.
        • MG.3 — magit-status actions (2026-07-25). Per-buffer action handlers on ActionHandlerRegistry: stage (s), unstage (u), discard (x), visit (), refresh (gr), close (q), commit-open (cc). Stored in MagitStatusGuard for RAII cleanup. 714 host tests pass. Audit fix (2026-07-26): the original handlers re-parsed rendered buffer text (parse_file_path splitting on the first space) instead of the structured SectionIndex data sections.rs/refresh.rs already build — this broke u and =/x on every Staged entry (the "new file" status label is two words, so the first-space split returned garbage), left <CR> a no-op on Stash/Commit lines, and truncated ='s toggle-off on any diff containing indentation-colliding context lines. Replaced with classify_line/StatusLine (fixed-format label matching)
          • a StatusBufferState::expanded map tracking exact inserted-line counts per entry (file diff / stash show / commit show, all via the same inline-toggle mechanism now). x's in-flight Effect::Confirm wiring (from a prior session) was also missing its CommandRegistry entry for action:magit-discard-execute — added.
        • MG.4 — magit-commit buffer (2026-07-25). MagitCommitMode major mode with commit/abort keymap. :magit-commit via OpenSyntheticBuffer. Staged diff preview + editable message region deferred to MG-future. Audit note (2026-07-26): empty-subject validation silently no-ops instead of the spec'd headerline error; amend doesn't pre-populate the previous commit message; no explicit refresh-status-after-commit. Not fixed in this pass — tracked as follow-up.
        • MG.5–MG.9 — closed. The 2026-07-26 functional audit that used to be quoted here was superseded by the magit plan's own 2026-07-27 close-out and by MG.13 (action handlers registered at boot rather than on activation), which invalidated its cross-buffer-hijack and dropped-registration findings outright. Re-verified against source 2026-09-18: magit-diff populates and refreshes, magit-log <CR> opens a synthetic revision buffer with no temp file, blame <CR>/p resolve and re-blame, root/file transient items resolve to real actions (guarded by every_root_dispatch_item_resolves_to_a_real_action_not_a_flag_fallback), branch c runs a pick-base → prompt-name wizard, and the rebase todo is built from git log --reverse with C-c C-c running a real git rebase -i. See slice-plans/magit.md for the per-slice record.

          • MG.10 — Polish (2026-07-25). Edge cases: not-a-git-repo message, detached HEAD, bare repo denial. Refresh on re-activation. All 714 host tests pass.
          • MG.11 — Cross-view uniformity (2026-07-27): <CR>, per-view highlighting, file-at-revision, expanded transients.
          • MG.12 — Destructive-action parity (2026-07-28). Branch delete, stash drop, and (when a rebase is actually in progress) rebase abort now go through the same Effect::Confirm-execute two-step magit-status's x already used; the ask half performs no git call, so n cannot mutate. Contract + the full asks/doesn't-ask set: ../architecture/magit.md §12.13.
          • MG.13 — Action handlers move from on_activate to Mode::action_handlers() (2026-07-28). Closed a production dead-chord window (chord resolves, mode active, no handler yet), plus two defects it exposed: the TUI test harness passed minors() where production passes keymap_gated_ids(), so no test could route a major-mode chord at all; and action:magit-refresh had five registrants, which boot-time registration turns from harmless into last-wins-plus-delete-on-drop (now one handler on magit-core-mode dispatching through a MagitView trait). Contract: ../architecture/magit.md §12.12b. Every magit mode migrated; zero handlers.register(...) calls remain in the crate. Shared actions (gr, s, u) are owned by magit-core-mode and dispatched per buffer through a new MagitView trait. Fixed two further pre-existing bugs on the way: q in *magit:status* was nondeterministic between BufferDelete and DismissPopup (two registrants, one action id — now uniformly DismissPopup, which an existing regression test already asserted, so this restores a guarantee rather than changing behaviour), and core-mode's own chords collapsed the same way with two magit buffers open. See the slice plan.
          • MG.14 — Headerline across every magit buffer (2026-07-28). One Headerline impl, ten per-view field builders — the per-view difference is data (a Vec<Field> tagged by git role), never a match buffer_kind. Fields ride the builder that already produces each buffer's text, so a header costs no git call of its own; set compares before it bumps, so an unchanged refresh triggers no repaint. Resolves colours live so :colorscheme lands on the row — the two headerlines that shipped before it capture colours at activation and go stale. Deleted SectionIndex::branch_status_line, dead since MG.2 and the origin of the false "headerline is active" claim the user docs carried. Contract: ../architecture/magit.md §4.11. Found, not fixed: Editor::do_buffer_delete never removes a deleted buffer's active_modes entry, so no mode's Guard::drop runs on :bd — a host-wide lifecycle gap affecting magit, diff, and ai-conversation alike. Bounded leak, not a correctness bug (buffer ids are never reused); wants its own host slice. See the slice plan.
          • MG.15 — Stash detail view (2026-07-28). <CR> in the stash list opens *magit:stash:<n>* (git stash show -p), closing the last exception to MG.11's <CR> uniformity rule. Found and fixed a live bug on the way: the stash list rendered <message> while every chord's stash_index_at_cursor parsed stash@{N}, so a/p/d in that buffer had always been silently dead — the MG.6/MG.8 failure class again (a line format whose writer and reader were only tested apart). The list now carries the label, list_row/parse_index are its single writer/reader with a round-trip test spanning them, and stash_styled_spans — a documented no-op until now — colours it. Also added a cross-cutting guard: every chord of every magit mode must name a registered action command AND have a boot handler, so the whole dead-chord class is covered for future chords by construction.
          • MG.16 — Remote/stash ex-command parity (2026-07-28). :magit-fetch, :magit-pull, :magit-push, :magit-stash — these operations were reachable from the C-c g transient and nowhere else, so they could not be scripted, rebound, or discovered under :. One body (spawn_remote_op) behind two front-ends per the unified-dispatch rule, with each operation's argv and echo verb defined once as a RemoteOp constant.
          • MG.17a — Transient flags + live preview (2026-07-29). Push --force-with-lease / --set-upstream, fetch --all / --prune, stash --include-untracked, each with a preview line rendering the exact git command. The blocker the plan didn't name: Argument being a no-op was known, but even a working Flag could not reach a handler — ActionContext carried no arguments, and TransientState lives in lattice-picker, which lattice-mode doesn't depend on. Resolved by giving ActionContext an args: Args (the currency ex-commands already carry) and projecting transient state onto the action's args_schema positionally, which makes MG.16's "one body, two front-ends" literal: :magit-push --force-with-lease and the -f toggle produce the same Args and run the same code. One RemoteOp::flags table feeds the schema, the menu, the preview, and the argv — chosen because this crate has produced the independent-writer-and-reader drift bug three times now (MG.6, MG.8, MG.15). Force-push is --force-with-lease only, pinned by test.
          • MG.17b — Transient arguments (2026-07-29). TransientItemKind::Argument was a no-op; it now opens a prompt and returns to the menu with the value filled in. The slice's real subject is that a transient's state had to survive a surface switch — the prompt takes the editing buffer and modal state, so the picker is torn down; Editor::pending_transient_argument owns spec + state + parent stack across the gap. <Esc> cancels the argument rather than the menu, and an empty submit clears the value instead of storing "" (git stash push -m "" is worse than no label). Shipped consumer: git stash push -m <message> — a mechanism with no consumer is untested by construction.
          • MG.20 — reset / revert / cherry-pick (2026-07-29). A, V, Os/Om/Oh in every view that shows a commit, built on the MG.13 MagitView seam (commit_at_cursor + workdir) so one boot-registered handler per operation serves all views without a per-view collision or a host-side kind branch. reset --hard is the only one behind a confirm — the others keep your changes, and prompting on those trains you to dismiss the prompt that matters. revert --no-edit, because git would otherwise hang on $EDITOR. Tag / merge / bisect / submodule / remotes deferred to MG.21. (Tag and merge landed in MG.23c1/c2; remote management in MG.21b/c/d — see the slice plan.)
          • MG.18c — hunk-level s / u / x (2026-07-29). The hunk under the cursor is parsed out of the buffer's own text and applied as a synthesized patch; the file-level path is untouched for a cursor that is not inside a hunk. The resolution lives in magit-core-mode (a hunk is a property of diff text, identical in every magit buffer), gated on MagitView::diff_source so a mismatched press is refused with a sentence instead of git's "patch does not apply" — and so x on a staged hunk cannot reverse it out of the worktree while leaving it staged.
          • MG.18d — the buffer and cursor survive a mutation (2026-07-29). A refresh rebuilds with the open entries' diffs inlined instead of collapsing them, and the cursor is restored by identity (file + side + hunk ordinal) rather than by row, landing on the hunk that took the staged one's place. The position travels on a SubsystemBoot::inbound bus so it reaches the screen without a keypress, as Effect::CursorMoveIn so it cannot land in a buffer the user moved to.
          • MG.18e — region (visual-mode) staging (2026-07-30). Select lines inside a hunk and s / u / x move only those: the body is rewritten (unselected + dropped, unselected - contextualised, counts recounted), mirrored for the reverse direction by one direction flag. ActionContext gained selection — design §5.2's "Visual mode IS the active region" reaching mode action handlers — and the transient-item path carries it too, without which a region x would ask about the selection and discard the whole hunk. MG.18 (hunk staging) is complete.
          • As of 2026-08-03 the open magit work is MG.23i+, MG.31 and the two blocked M/e rows; MG.22's parser is deferred past v1 — per-slice status and sequencing live in slice-plans/magit.md, which is authoritative for this subsystem. (The 2026-07-26 audit found this ledger and that plan had drifted apart and from source; magit status is tracked in one place now.) As of 2026-07-31 that tail is MG.23j (repo-level cherry-pick / revert / reset rows via a commit picker) and MG.23i+ (bisect, submodule, remote management — the same set MG.21 names). As of 2026-08-01, MG.23j has landed and remote management shipped as MG.21b/c/dmagit-remote-mode (:magit-remote, M on the dispatch), a buffer rather than magit's transient because our transients cannot render the URLs — and bisect as MG.21e/f/g: a BISECTING N left alert on magit-status's headerline plus a B sub-transient gated on whether one is running, with no buffer of its own — and submodules as MG.21h/i (magit-submodule-mode, :magit-submodule, o on the dispatch). MG.21 is complete. It surfaced one architectural gap worth naming: magit's working directory is process-wide, so a submodule's own status buffer cannot be opened, and per-buffer workdir is a prerequisite for worktree support too. As of 2026-08-02 MG.19 is complete too (side-by-side + do/dp), and as of 2026-08-03 MG.22's tree-sitter-diff parser is deferred past v1 — the mode itself (chords, <CR>, options) is done, and the slice keeps ⛔ so the plan stays out of archive/.

          multibuffer-views (in design, 2026-05-28)

          Design fragment: ../architecture/multibuffer-views.md. Synopsis in ../architecture/design.md §5.14. A MultibufferDocument is a Document (trait) whose content is composed of N anchored excerpts; edits propagate through the standard pipeline to source buffers. Lights up project-wide diff, AI multi-file openDiff, search-as-buffer, LSP references-as-buffer, and diagnostics-as-buffer with one implementation.

          • M.-0 (2026-05-31) — Document hot-path bench baseline. Two bench groups in crates/lattice-core/benches/document_hotpath.rs: document_read_p99_us::viewport_walk (per-frame 50-line reads + selections / version) and document_edit_p99_us (insert_at_middle / delete_at_middle / set_selections_motion) at the three canonical sizes (10 / 1k / 100k lines). Numbers recorded in docs/dev/operations/benchmarks.md. Originally framed as the M.0 no-regression gate; after the M.0 design shifted to the handle-layer trait (Path B) the benches remain as general-purpose regression infrastructure for the inner lattice-core::Document struct.

          • M.0 (2026-05-31) — Document trait at the handle layer. Path B per design fragment §3.1 — the trait abstracts over RopeDocumentHandle (today, rope-backed actor handle) and a future MultibufferDocumentHandle (M.1). Five-phase landing:

            • Phase A (commit 6d57483) — define Document: Send + Sync + 'static + Debug in lattice-runtime. Read methods carry defaults derived from snapshot() so impls only provide snapshot() / snapshot_cache() + writes. Add impl Document for DocumentHandle via fully-qualified delegation. No behaviour change.
            • Phase B+C (commit 7bcd1dc) — rewrite do_edit to use slot replacement only. Reload-same-buffer and brand-new- file paths collapse into one open_fresh_into_active_slot helper. Remove ActorMsg::Replace, DocumentHandle:: replace, Editor::replace_document_blocking, TUI wrapper, and the replace_swaps_document_state test. User-visible semantic change: :edit current_path.rs now allocates a fresh BufferId instead of swapping content in-place (vim quirk → emacs find-file / everything-is- a-buffer alignment).
            • Phase D (commit 418fd64) — switch Editor.document from concrete DocumentHandle to lattice_runtime::ActiveDocument (newtype around Arc<dyn Document> with Default + Clone + Deref<Target = dyn Document> + Debug). BufferRegistry::DocumentEntry.handle retyped to Arc<dyn Document> for polymorphic storage. BufferStore::handle_for trait method (lattice-mode) retyped accordingly. All construction / assignment / consumer sites in host + TUI + lsp bench updated.
            • Phase E (commit 9eaafd4) — workspace-wide rename DocumentHandleRopeDocumentHandle. 60 substitutions across 17 files. Disambiguates the trait hierarchy ahead of M.1's MultibufferDocumentHandle sibling.

            Inner lattice-core::Document struct, DocumentActor, PublishedSnapshot, DocumentSnapshot, and the mpsc write path are unchanged — the trait is a thin abstraction over what already existed. Trait dispatch through Arc<dyn Document> adds one vtable indirection (~1–3 ns), lost in noise against actor mpsc round-trip (~µs) and per-frame snapshot Arc-load (~17 ns / ~2 ns cached).

            Workspace build clean. 1461 lattice-workspace tests pass. Touched: crates/lattice-runtime/{src/document.rs, src/handle.rs, src/actor.rs, src/lib.rs}, crates/lattice-mode/src/buffer_store.rs, crates/lattice-host/src/{editor.rs, editor_boot.rs, buffer_registry.rs, synthetic_buffers.rs, dispatch.rs, render_state.rs, diff/subsystem.rs, Cargo.toml}, crates/lattice-ui-tui/src/app/{dispatch.rs, lifecycle.rs, options.rs}, crates/lattice-lsp/{src/lib.rs, benches/mode_activate.rs}, crates/lattice-protocol/src/lib.rs, crates/lattice-runtime/benches/actor.rs.

          • M.1 (2026-05-31) — MultibufferDocumentHandle static read-only. New module crates/lattice-runtime/src/multibuffer.rs introduces ExcerptId, Excerpt, RowEntry, RowTranslation, MultibufferDocumentHandle, and MultibufferError. The handle impls Document (Path B sibling to RopeDocumentHandle) — snapshot() returns the composed DocumentSnapshot via lock-free ArcSwap load; all writes return Pending::ready(Err(RuntimeError:: ReadOnly)) until M.3 lands edit propagation. Composition is eager at construction; recompose() is manual (M.4 wires auto-rebuild via EventKind::DocumentChanged subscription). Plumbing changes:

            • Pending::ready(Result<T, RuntimeError>) constructor for synthetic immediately-resolving futures (used by read-only impls to reject writes without an actor).
            • RuntimeError::ReadOnly variant distinguishing "buffer doesn't accept writes by design" from ActorGone / Core / Grammar. Sub-slices:
            • M.1.a (commit 6ce816e) — module foundation: types
              • MultibufferDocumentHandle + impl Document + 7 targeted tests covering composition, write rejection, construction errors, trait-object dispatch, manual recompose.
            • M.1.b (commit dba4970) — registry round-trip test proving MultibufferDocumentHandle flows through the existing BufferData::Document slot (no new variant needed — M.0 Phase D's Arc<dyn Document> typing already accepts it). Reachable via BufferRegistry::document_handle and the BufferStore::handle_for mode-facing trait. Disambiguation: the slice plan originally listed "source-buffer edits propagate" + "closing a source auto-removes orphaned excerpts" under M.1; both belong architecturally to M.4 (they need the same event- subscription infrastructure as the rest of M.4's live-update story). The slice plan + this ledger now scope them to M.4; M.1 closes on the static-composition
            • manual-recompose foundation. Workspace build clean. 1461 baseline + 7 multibuffer + 1 registry round-trip = 1469 tests passing. Touched: crates/lattice-runtime/src/{multibuffer.rs (new), lib.rs, pending.rs}, crates/lattice-host/src/buffer_registry.rs.
          • 🚧 M.2 — Excerpt rendering. Sub-sliced per design §3.6 (2026-05-31): multibuffer concerns extract into a dedicated lattice-multibuffer crate; MultibufferMode becomes the major mode for BufferKind::Multibuffer; motions register through the mode's keymap with the typed handle reached via the mode's per-buffer context.

            • M.2.a (commit 7b436e3) — initial MultibufferHeaderProvider in lattice-host::multibuffer
              • ExcerptHeader / ExcerptHeaderStyle on Excerpt. Five tests. To be moved into the dedicated crate in M.2.b.1.
            • M.2.b.0 (2026-05-31) — reconnaissance of lattice-mode + lattice-grammar APIs. Findings: (a) Mode trait already has typed Guard: Send + 'static — ready for MultibufferModeGuard. (b) BufferKind → major-mode activation pipeline (crate::modes::resolve_major_mode) already in place; BufferKind::Multibuffer slots in symmetrically. (c) MotionContext was intentionally narrow (no buffer_id, no service access) — kind-specific motions couldn't reach mode state. Prerequisite slice M.2.b.0.A extends the grammar API.
            • M.2.b.0.A (commit 6ec958d) — grammar API extension. MotionContext.buffer_id field; execute(...)
              • execute_motion_only(...) gain buffer_id parameter; ActorMsg::Dispatch carries buffer_id; RopeDocumentHandle stores its own buffer_id (set at spawn_document(buffer_id, document, registry)). Built-in motions ignore the new field; kind-specific handlers use it for service-registry lookup at dispatch time. Grammar stays free of lattice-mode coupling. 1461 tests pass. 17 files touched.
            • M.2.b.1 (commit 1e16fd7) — extract lattice-multibuffer crate. Data types moved from lattice-runtime::multibuffer; header provider moved from lattice-host::multibuffer; BufferKind::Multibuffer variant added in lattice-core. Known gap: M.2.b.1 declared BufferKind::Multibuffer but no matching BufferData::Multibuffer(DocumentEntry) variant. The enum variant is unreachable today (no producer constructs an entry whose kind() returns Multibuffer); H.1 fixes this by adding the missing BufferData variant. Workspace + tests green.
            • M.2 paused 2026-05-31 → resumed 2026-06-01. Honest architectural review surfaced shared host-coupling across every future kind-specific feature; H-series opened to land kind-agnostic infrastructure. H-series closed at H.2 (H.3 deferred — see kind-agnostic-buffers.md §10); 2026-06-01 M.2.b.2 design pass locked the shape: five abstractions covering the in-tree extension-crate seam without event-bus indirection. See multibuffer-views.md §3.7 for the full design + worked SearchProvider example.
            • M.2.b.2 (landed 2026-06-01, commit 501bb41) — Five abstractions per architecture §3.7: (1) ModeActivator trait in lattice-mode + impl ModeActivator for Editor in lattice-host. (2) MultibufferMode declaring target_buffer_kind() = Some(Multibuffer), contributing ReadOnly + NoFile. (3) MultibufferRegistry trait + InMemoryMultibufferRegistry impl in lattice-multibuffer/src/registry.rs returning typed Arc<MultibufferDocumentHandle> keyed by view BufferId. (4) register_multibuffer_modes(®istry, &events, &mut services) boot helper wiring the mode + the service + the DocumentClosed cleanup subscriber. (5) create_multibuffer_view(activator, sources, excerpts, name, flags) -> BufferId atomically inserting via H.1's BufferStore::insert_document_buffer + registering the typed handle + activating multibuffer-mode via the activator. Plus MultibufferDocumentHandle::append_excerpts / replace_excerpts for provider-driven updates. Host gains one boot-time call; zero match BufferKind::Multibuffer arms. Tests cover round-trip, cleanup, stub-provider handle reach.
            • M.2.b.3 (landed 2026-06-01, commit 3274214) — ]e / [e / ]E / [E motions registered through lattice-grammar; bound in the multibuffer-mode keymap layer via lattice-host::multibuffer_keymap. Pure helpers (excerpt_start_rows, containing_excerpt_index, next_excerpt_start_row, prev_excerpt_start_row, file_boundary_indices, next_file_boundary_row, prev_file_boundary_row) carry the geometry logic. File-boundary semantic: ]E / [E jump to the FIRST excerpt of the next / prev file group. Counts compose (3]e). 8 unit tests; workspace tests green.
            • M.2.c (landed 2026-06-01, commit 7a7793b) — motion + compose + translation-rebuild benches in crates/lattice-multibuffer/benches/. Two bench files (multibuffer_motion.rs, multibuffer_compose.rs). --quick smoke: ]e / [e at 50 excerpts ~80 ns; at 5k ~8 µs. ]E / [E at 50 excerpts ~120 ns; at 5k ~12 µs — all ~690× under the 8.3 ms one-frame ceiling at 120 Hz. Compose / translation rebuild gates per arch §7 carry over; baseline numbers land on the next regression sweep. See benchmarks.md ## M.2.c section.

          kind-agnostic buffer + mode infrastructure (H-series, closed 2026-06-01)

          Design fragment: ../architecture/kind-agnostic-buffers.md. Slice plan: archive/kind-agnostic-buffers.md. Status: H-series closed after H.2. H.1 + H.2 removed host's hardcoded coupling to specific buffer kinds for the in-tree extension-crate case (the only case that exists pre-v1). H.3 deferred to the WASM Component Model plugin host slice — see architecture §10 for the rationale.

          • H.1 (2026-05-31, commit 22ee033) — BufferStore::insert_document_buffer(id, kind, handle: Arc<dyn Document>, flags, name) trait method in lattice-mode. Host's BufferRegistry impl maps the kind tag to the appropriate BufferData::Document / Messages / Multibuffer variant. Added BufferData::Multibuffer(DocumentEntry) variant + matching BufferEntry::kind() arm. Closed the M.2.b.1 unreachable-variant gap. No type hoisting needed — primitives (BufferId, BufferKind, Arc<dyn Document>, BufferFlags, Option<String>) already lived in lattice-core / lattice-runtime. Kinds whose payload is NOT Document (FileTree, Oil, Terminal, Help) keep their host-internal insertion path. Existing producers didn't migrate.

          • H.2 (2026-06-01, commit 6f32f94) — Mode::target_buffer_kind() -> Option<BufferKind> (default None). ModeRegistry::find_major_for_kind(BufferKind) -> Option<ModeId> indexes registered modes via a kind_index: HashMap<BufferKind, ModeId> populated at register-time (first-registration-wins on clobber; second claim logs tracing::warn!). Overrides landed on FileTreeMode → FileTree, OilMode → Oil, MarkdownMode → Help (Option B: markdown-mode also serves Document + Lang::Markdown via the Lang-detection path; the two cohabit), MessagesMode → Messages, TerminalMode → Terminal. Host's crate::modes::resolve_major_mode(®istry, kind, lang) rewrote to use the lookup; the hardcoded match block disappeared. BufferKind gained Hash; lattice-mode gained a tracing dep. Drive-by fixes for unrelated bench rot (lattice-cells virtual_rows bench, lattice-lsp mode_activate bench, lattice-ui-tui keymap bench). 312 insertions, 64 deletions, 15 files.

          • H.3Deferred 2026-06-01. Original design preserved in architecture §3 / §10 for the future WASM Component Model plugin host slice. In-tree extension crates (lattice-multibuffer today) reach activation through the ModeActivator trait introduced in multibuffer-views.md §3.7 — no event-bus indirection needed because in-tree dispatch already runs on the App thread with &mut Editor in scope. The event-bus path is the right shape for capability- gated WASM plugin producers and lands when that work begins.

          • M.3 (landed 2026-06-01) — Edit propagation. MultibufferDocumentHandle::apply_edit translates composed coordinates → source coordinates via the excerpt walk, forwards to the source's Document::apply_edit. Boundary clipping per architecture §4: cross-excerpt edits clip end to the start excerpt's last source row end-of-line (read from the source's current snapshot in build_source_edit). apply_edit_batch translates up-front + awaits sequentially via the new Pending::spawn helper in lattice-runtime — combines N source Pendings into one without blocking the runtime. undo / redo fan out across every source the view references; errors per-source tolerated. save / save_as / set_selections / dispatch_with_cancel stay ReadOnly (multibuffers aren't files; selections are view-owned; grammar runs at host layer). MultibufferMode::options() drops ReadOnly — providers that need read-only views layer a minor contributing it. Pending::spawn is the new public Pending constructor that takes a Future<Output=Result<T,RuntimeError>> and returns a Pending resolving when the future completes. Tests: 7 new M.3 tests + the existing dispatches_via_dyn_document updated. Workspace tests green.

          • M.4 (landed 2026-06-01) — Live updates from source buffers + headerline API. MultibufferDocumentHandle::attach_event_subscriptions(events) subscribes the view to DocumentChanged + DocumentClosed; forwarder task filters by source DocumentId, recomposes on edits, prunes + publishes MultibufferSourceClosed { view, source } on close. Forwarder holds Weak<MultibufferInner>; subscriptions tracked in SubscriptionBookkeeping on Inner so Drop unsubscribes. Idempotent re-attach. Graceful no-runtime fallback (test path). New HeaderlineStatus enum (Idle / InProgress / Complete / Failed), set_headerline / headerline() methods, MultibufferHeaderlineChanged typed event. create_multibuffer_view auto-calls attach_event_subscriptions when EventBus is in the ServiceRegistry; boot registers Arc<EventBus> there. 5 new M.4 tests; workspace tests green. Anchor sliding + self-loop debouncer + bench deferred to M.4.1 — the Excerpt::{start_line, end_line}Anchor refactor is a significant data-model change deserving its own slice.

          • M.4.1 (landed 2026-06-01) — Anchor sliding + source-edit bench. slide_anchors_for_source walks AppliedEdits from a source's DocumentChanged event and shifts excerpts whose start_line is strictly below the edit's original_range.end.line by the row delta. Conservative: edits AT or overlapping the excerpt's start don't slide (recompose picks up new content; false-positive slides during boundary edits would surprise more than stale positions). Excerpt struct keeps integer line bounds — a first-class Anchor primitive can land later if column tracking proves load-bearing. 5 new M.4.1 tests cover insert-above-slides-down, delete-above-slides-up, edit-below no-op, overlapping no-slide, other-source no-cross. Bench multibuffer_source_edit_p99_us lands in benches/multibuffer_compose.rs: 27 µs at 1k excerpts × 10 sources (CI gate ≤ 200 µs — 7× under). Self-loop debouncer deferred to M.4.2 if profiling warrants.

          • 📝 M.4.2 — Self-loop debouncer (skip recompose when multibuffer's own edit caused the source change). Lands when profiling shows the redundant recompose matters.

          • M.5 (landed 2026-06-01) — Expand-context affordance. MultibufferDocumentHandle::expand_excerpt_at(cursor_row, delta_rows) does symmetric grow/shrink around the cursor's excerpt, clipping to source bounds (row 0 / line_count - 1), no-op when contract would invert OR cursor sits outside the excerpt OR delta is zero. Recomposes + publishes after the mutation. :multibuffer-expand [n] / :multibuffer-contract [n] ex-commands wired via AppEffect::MultibufferExpand { delta }Action::MultibufferExpand { delta }Editor::do_multibuffer_expand (pulls active view via MultibufferRegistry, reads cursor row from primary selection head, calls expand_excerpt_at). Default n = 5. Args: optional non-negative integer; bad args → BadArgs. Registered via register_multibuffer_ex_commands in multibuffer_keymap.rs at boot. 8 new M.5 tests; workspace tests green. + / - on the excerpt header deferred — header-context keymap is a wider design pass.

          • M.6.0 (landed 2026-06-01) — First provider foundation: lattice-multibuffer::providers::search. Lives in crates/lattice-multibuffer/src/providers/search.rs behind the search cargo feature (default-on; pulls ignore only). Ships: ProjectSearchService trait + InMem impl + handle; four typed events (ProjectSearchBatchReady, Completed, Refreshed, ProgressUpdated); ProjectSearchMultibufferMode minor with per-view forwarder task that filters typed events by view BufferId and updates headerline; project_search(activator, query, options) -> Option<BufferId> trigger; async scan task using ignore::Walk + literal matching; host :search <query> ex-command via AppEffect::SearchTriggerAction::SearchTriggerEditor::do_search. Boot integration in editor_boot.rs registers mode (mode-registry block) and service (services block) under #[cfg(feature = "search")]. Host gains its own search feature forwarding to lattice-multibuffer/search. 4 unit tests pass; workspace tests green. Deferred to M.6.1: source-load + per-hit excerpt population, jump-to-source keymap, query-refresh chord, bench gate.

          • M.6.1 (landed 2026-06-01) — Source-load + jump-to-source

            • refresh + bench infrastructure. Forwarder in ProjectSearchMultibufferMode::on_activate opens each hit's file via tokio::task::spawn_blocking(fs::read_to_string), spawns a fresh RopeDocumentHandle, adds to the view's source map, appends 1-row excerpts per hit. Headerline shows running hit count. <CR> chord routes through AppEffect::SearchJumpToSourceAction::SearchJumpToSourceEditor::do_search_jump_to_source (walks excerpts to find cursor's excerpt, looks up source path via ProjectSearchService::source_path, calls do_edit). gr chord routes through AppEffect::SearchRefreshAction::SearchRefreshEditor::do_search_refresh (reads query+options, clears view, resets state, spawns fresh scan — attach_task cancels the prior). gr shadows LSP-references gr only inside search views (K.1.c minor-mode precedence). ActionIds gained search_jump_to_source / search_refresh. Keymap layer pushed under MinorMode(project-search-multibuffer-mode). New bench crates/lattice-multibuffer/benches/project_search.rs with synthetic 1k-file corpus + BenchActivator mock; CI gate project_search_first_batch_p99_ms ≤ 50 ms; baseline numbers land on next regression sweep. Deferred to M.6.2: per-file dedup across batches (currently a file hit in two batches loads twice — known tradeoff; source_paths map tracks the mapping but isn't consulted yet for dedup); cursor-placement at the matched row inside the opened buffer (do_edit lands at row 0); regex via grep-matcher + aho-corasick.
          • M.6.2 (landed 2026-06-01) — Search dedup + cursor-placement. New ProjectSearchService::find_source_for_path(view, path) -> Option<BufferId> reverse lookup. Forwarder's BatchReady arm checks for an existing source before spawning a new RopeDocumentHandle — files with hits in multiple batches reuse the same source. Editor::do_search_jump_to_source follows do_edit with set_selections_blocking(SelectionSet::single(Selection::cursor(Position::new(source_row, 0)))) so the cursor lands at the matched line. Per-view scoping: same path in two different views is not shared. 1 new unit test; workspace tests green.

          • M.6.3 (landed 2026-06-01) — Regex matching. New ProjectSearchOptions::regex: bool (default false). Matcher enum (Literal { needle, case_sensitive } / Regex(fancy_regex::Regex)) compiled once per scan via build_matcher. Case-insensitivity layered by injecting (?i) into the regex pattern. Invalid regex → SearchStatus::Failed

            • early ProjectSearchCompleted with zero counts. Chose fancy-regex (workspace-uniform) over grep-matcher + aho-corasick (rg-style; overkill for the per-line model). 4 new tests cover case-sensitive + case-insensitive regex
            • alternation + invalid-pattern rejection. Literal-mode tests retained. Workspace tests green. (locked 2026-06-01 — all in-tree providers live within lattice-multibuffer, feature-gated). Adds search cargo feature pulling ignore / grep-matcher / aho-corasick. Ships ProjectSearchService trait + impl + handle, typed events (ProjectSearchBatchReady / Completed / Refreshed / ProgressUpdated), ProjectSearchMultibufferMode, project_search(activator, query, options) -> BufferId public trigger using the async pattern from architecture §3.7 (empty view immediately, headerline shows progress, scan runs on tokio worker threads, batched events update the view). Host's :search <query> ex-command thin-wraps the trigger. Bench: project_search_first_batch_p99_ms ≤ 50ms at 1k-file corpus.
          • M.6.X (landed 2026-06-01) — UI-discipline retrofit (would have been M.6.0 had the four-artefact contract been honoured). M.6.0–M.6.3 shipped with a latent paramount-goal-1 violation: run_scan walked ignore::Walk synchronously on the editor actor's current_thread runtime (editor_actor.rs:575), freezing :search for the scan duration. The project_search_first_batch_p99_ms bench measured throughput, not actor responsiveness — structurally incapable of catching the failure. Fix: run_scan extracts a run_scan_blocking sync helper wrapped in tokio::task::spawn_blocking; fs work moves to tokio's blocking pool; current_thread runtime stays free. Missing artefacts now shipped: test tests/ui_responsive_during_scan.rs (probe-gap assertion on a current_thread runtime with 500-file corpus, threshold 50 ms — pre-fix hundreds of ms); criterion bench benches/actor_latency_during_scan.rs (CI-tracked p99 probe-gap with 1k-file corpus); feedback_no_ui_thread_work memory updated with the bench/test-pair pattern any fs / net / blocking provider must accompany its code with. Scan cancellation on refresh (old task keeps running on blocking pool until the walk completes) is a separate follow-up.

          • 📝 M.6.4 — wgrep-style edit-results follow-up. Drop ReadOnly = true from ProjectSearchMultibufferMode::options(), open sources through BufferRegistry, hook :w to save source files. Tracked alongside M.6 because it's the user- facing completeness of the search provider (see multibuffer-views.md §3.7). Paused until K.2 lands — source-write plumbing touches the same translation layer that K.2 reshapes.

          • K.2 — Keymap substrate (mode-owned bindings). Closed (2026-06-01 → 2026-06-02). The lattice-mode::Keymap stub (crates/lattice-mode/src/contributions.rs:19, TODO since mode-system design) became the real contribution type; Mode::keymap() is load-bearing and the host translation pass routes the result into the matcher trie at the same KeymapLayer::MinorMode(mode_id) the legacy host-glue push_layer calls used. Multibuffer + project-search are fully migrated and the host-side multibuffer_keymap.rs glue is deleted. Substrate now in place for the MO.x cleanup arc (LSP / Oil / Snippet / diff-mode).

            Substrate sub-slices (K.2.1 → K.2.4): K.2.1 chord primitives → lattice-protocol (commit d075a66); K.2.2 BindingModelattice-mode (commit d3dbe87); K.2.3 real Keymap contribution type + bind_chord ergonomic surface (commit c6c3ffe); K.2.4 host translation pass walking ModeRegistry (commit ff9f9bf).

            K.2.4.A polish + unification sub-arc: K.2.4.A.0.1 keymap_entry! + KeymapEntry substrate move to lattice-mode::keymap_entry (commit 4f763d5; later superseded by a7c7799b — single definition back in lattice-keymap, re-exported by lattice-mode, no duplicate); K.2.4.A.0.2 Keymap::from_entries() + KeymapBinding.doc (commit 6461f56); K.2.4.A.0.3 translation pass entry resolution (commit 81c4600); K.2.4.A.0.4 chain+table composability test (commit eaf9e33); K.2.4.A.0.5 sub-arc docs + §11.2.2 (commit f9a15cd); K.2.4.A.1 resolved-binding indicator on :describe-key (commit 174cffd); K.2.4.A.2 friendly layer labels (commit 90be78c); K.2.4.A.3 source rendering via as_link() (commit 9e01634); K.2.4.A.4 canonical names in runtime registry (commit 28562c5); K.2.4.A.5 user docs + mode-author guide (commit 3764c7d).

            K.2.5 multibuffer migration (commit 7719e27) — multibuffer_keymap.rs deleted (-292 LOC); MultibufferMode::keymap() and ProjectSearchMultibufferMode::keymap() return the table form. Explicit push_layer calls retired — K.2.4 pass handles them via Mode::keymap(). Diff-mode's helper still in place, migration tracked under mode-ownership-cleanup.md as MO.x.

            K.2.6 + K.2.7 docs (this slice) — mode-architecture.md §13 rewritten (the "long-term ideal" framing retires; Mode::keymap() IS the convention now); slice plan + ledger refreshed with commit hashes and the sub-arc summary table; symbolic unblock note for MO.1-MO.4

            • new MO.x diff-mode-keymap-migration slice.

            M.6.4+ now unblocked (the source-write plumbing K.2 was pausing it on is shipped). BENCHMARKS row deferred to a measured profiling sweep — the existing benchmarks.md rows are criterion-derived and a stub row would be misleading.

            Design at ../architecture/keymap-architecture.md §11 (chain ergonomic surface in §11.2.1; table form in §11.2.2); convention update in ../architecture/mode-architecture.md §13; mode-author guide at ../notes/mode-keymap-authoring.md; full sequencing + commit table at ../archive/keymap-substrate.md.

          • K.3 — Help-prefix bindings (<C-h> map, Normal-mode only). Closed 2026-06-02. Emacs-style discoverability for the §5.11 self-documenting help facility. Landed on top of the closed K.2 substrate (commit 0938572) as a thin slice.

            K.3.0 ambiguous-leaf trie audit + Option 2 decision. Found keymap_trie.rs:251-263 returns Bound immediately on a leaf node even when children exist, and the dispatcher has no timeoutlen machinery anywhere. Rather than introduce timer-driven dispatch for a single affordance, dropped the bare <C-h> leaf binding and used the explicit <C-h><C-h> + <C-h>? forms for help-for-help. One keystroke of friction; zero new infra. Full timeoutlen (Option 1) becomes the right answer when timer-driven dispatch arrives for other reasons (a future count-absorbing slice, say); at that point bare <C-h> can land.

            K.3.1 :help-for-help ex-command alias (commit ed2a1bf) — alias row added to the host's ex-command alias table; resolves to canonical ex:help.

            K.3.2 keymap_help.rs module (commit ed2a1bf) — registers the §12.1 binding table at KeymapLayer::Builtin, BindingMode::Normal. Takes &CommandRegistry, resolves each row's canonical ex:* name via id_by_name; unresolvable warns and skips. Boot wired in editor_boot.rs right after the four register_<mode>_bindings calls. 6 unit tests.

            K.3.3 mode-scope enforcement (commit 05c2e25) — 5 additional unit tests covering Visual / OperatorPending / Command (cmdline) / Search / Replace, all asserting the <C-h>k lookup returns Unbound (the K.1.c per-keystroke filter enforces this, but the tests pin the contract).

            K.3.4 doc artefacts (this slice) — keymap-architecture.md §12 rewritten (Option-2 binding table; §12.3 records the decision + discarded alternatives + when Option 1 retires it); slice plan flipped to ✅ with commit refs; user docs in docs/user/modes.md get the <C-h> discovery pointer. BENCHMARKS row deferred — pre-Option-2 the bench would have measured timer-driven dispatch; Option 2 removes the timer so the existing keymap_trie_lookup_partial bench (11.8ns) covers it.

            Design at ../architecture/keymap-architecture.md §12; sequencing + commit refs at ../archive/help-prefix.md.

          • K.4 — Multibuffer-is-a-regular-buffer audit + integration verification. Triggered by M.6 testing surfacing four latent failures (silent EventBus None, current_thread freeze, contains_document exclusion, vim grammar broken). Closes the integration-verification gap M.2.b shipped without. Landed: K.4.0 audit doc (8bc77e4); K.4.1.a test scaffold + K.4.1.b motion + visual-mode tests (6a14732, 83d3daa); K.4.2 build_cells_panes kind-gate extension (8bc77e4); K.4.3 renderer syntax-cell gate (8bc77e4); K.4.4 dispatch_blocking host grammar (8bc77e4); K.4.5 visual-mode highlights — set_selections stores rather than rejects (f45256e); K.4.6 excerpt-header virtual-row pipeline — ModeActivator::register_virtual_row_provider

            • per-pane matrix renderer + search provider per-file excerpt collapse (07acd33 + follow-ons); K.4.7 per-excerpt syntax highlighting — multibuffer rides per-source syntax cells, shifting spans to composed coordinates (2026-06-08); K.4.8 :ls Multibuffer/Messages split (6299564); K.4.9 audit-comment pass (4c4631d); K.4.10 convention codification (29ffec3); K.4.11 dispatch_with_cancel proper impl — grammar dispatch owned by MultibufferDocumentHandle, kind-branch in host deleted (d05348d, f598a72). Design at ../architecture/multibuffer-is-a-regular-buffer.md; sequencing at slice-plans/archive/multibuffer-is-a-regular-buffer.md. Convention codified in feedback_buffers_no_special_case memory.
          • M.7 — Excerpt fold provider. ExcerptFoldProvider registers one fold range per excerpt's composed-row range into the existing fold registry via FoldOverlayService. MultibufferMode::on_activate registers the provider and holds it in MultibufferModeGuard; Drop deregisters on mode exit. No new keymaps — the standard z* vocabulary (za / zo / zc / zR / zM, foldlevel=N) covers excerpts identically to syntactic / marker folds. Composes with diff-system D.3.f hunk folds via vim's "smallest enclosing fold wins" on za. 3 tests: one-fold-per-excerpt, namespace-distinct, empty-when-no-excerpts. See multibuffer-views.md §6.5.

          • M.8 — File-boundary fold provider. FileBoundaryFoldProvider registers one fold range per distinct source: BufferId covering the union of that file's excerpts. Registered alongside ExcerptFoldProvider in MultibufferMode::on_activate. No new keymaps — same vocabulary as M.7. Nesting: file > excerpt > hunk on za. 3 tests: one-fold-per-source-file (non-contiguous excerpts span correctly), namespace-distinct, empty-when-no-excerpts. See multibuffer-views.md §6.5.

          Follow-on consumers (post-M.6, each its own slice sequence, not yet committed): ProjectDiffProvider (composes with diff-system D.7), AIProposedEditsProvider (composes with diff-system D.3 — the AI multi-file openDiff flow), LspReferencesProvider (replaces gr picker with editable multibuffer), DiagnosticsProvider (replaces :diagnostics list with editable multibuffer).

          Open questions §8 (excerpt-boundary clip vs extend, source-buffer close behaviour, fold-as-unit, diff over multibuffer, tree-sitter per-excerpt) resolved per slice.

          In-progress

          Phase 4.2 (navigation) and Phase 4.3 (edits) are both complete — the phase table's Phase 4 row already says so ("4.1–4.5 complete; 11 wire-only rows with deferred trigger UX, all post-1.0 / upstream-gated"). This was still reading "9/12 shipped + 1 partial" / "3/9 shipped" as of 2026-09-18; stale, corrected. The per-item ledger below is kept as the historical build record, not a live status claim. The actual active frontier is Phase 5.8.AF.5 — UI-thread relocation (paramount goal #4 enforcement: moving every remaining renderer-thread drain onto background tokio tasks via RenderState + PerBufferCache), which is also what README's status block names as current work. See Phase 5.8.AF.5 — UI-thread relocation above for its slice ledger.

          • ✅ hover (K), definition (gd), declaration (gD), typeDefinition (gy), implementation (gI), references (gr), documentSymbol (:lsp-symbols), workspaceSymbol (:lsp-workspace-symbol).
          • ✅ completion (:complete picker bridge -- buffer-level Insert-mode completion shell + snippet expansion + lazy resolve; see 4.2.g below -- "4.2.g complete" at the tail of the per-item ledger).
          • ✅ formatting + rangeFormatting (:format / :format-range).
          • ✅ signatureHelp via :signature-help + Insert-mode trigger-char autopilot (typing ( / , etc. fires the request automatically).
          • ✅ rename + prepareRename via :rename <name> (alias :rn). Active buffer applies as one undo unit; cross-file edits open via :e then apply per-file. WorkspaceEdit flattening covers both legacy changes map and modern document_changes shape.
          • ✅ willSave / didSave notifications fan out from App::save_blocking. Each attached server advertising the matching capability gets a fire-and-forget notification; didSave attaches the post-save rope text when includeText is set. willSaveWaitUntil typed wrapper exists; the App-side block-on-response (format-on-save) is queued.
          • :code-actions (:ca) -- vertico picker over LSP code actions; resolves lazy edit via codeAction/resolve when the action arrived without inline edit + command; routes Command payloads through workspace/executeCommand. Edits land via the rename apply path (per-file one-undo-unit).
          • ✅ onTypeFormatting Insert-mode autopilot. Typing a server-advertised trigger character fires textDocument/onTypeFormatting; edits apply via the format channel.
          • ✅ willSaveWaitUntil format-on-save: App::save_blocking blocks for up to 500ms per server collecting pre-save edits, applies them as one undo unit, then writes to disk. Buggy / slow servers can't hang the save (token
            • timeout).
          • Multi-result lookups + :diagnostics route through one vertico picker (PickerSource::LspLocations + PickerAction::JumpToLspLocation); single-result nav still jumps directly per vim convention.
          • The four nav flavours share do_lsp_nav_request(LspNavKind) -- one dispatch path; the kind selects the LSP method and drives the kind-aware echo verb ("no implementations found" vs. "no definitions found").
          • ✅ Tag stack: gd family + multi-result picker accept push onto App.tag_stack; <C-t> pops. Distinct from the jump list (<C-o>/<C-i>); the two have different push semantics and may have different lengths.
          • ✅ Jump list propagation audited: every LSP nav, picker accept, search submit, n/N, help-link follow records (BufferKind, BufferId, Position, source) so cross-buffer walks just work. The previous active_buffer == Document gates on search submit + repeat search are gone.

          Remaining 4.2:

          • Buffer-level Insert-mode completion -- design spec at ../architecture/insert-completion.md. 4.2.g.1 (shell + buffer-words + popup widget + minor-mode keymap) ✅; 4.2.g.2 (LSP source + isIncomplete refresh + typed routing payload via CandidateData::Extension / App.insert_completion_lsp_meta sidecar) ✅; 4.2.g.3 (docs side popup + lazy completionItem/resolve + <C-f> / <C-b> paging) ✅; 4.2.g.4 (lattice-snippet crate -- TextMate JSON parser + render walker + variable context + active-snippet state machine + friendly-snippets compat; host integration -- gen:snippet source, accept routing for snippet candidates AND LSP insertTextFormat == Snippet items, <C-x><C-s> direct expand, active-snippet minor mode for <Tab> / <S-Tab> / <Esc>, :snippet-expand / :reload-snippets ex-commands) ✅; 4.2.g.5 sliced 3 ways for landing -- (1/3) frequency ranking (App-side (text, kind) -> u32 accept map bumped in do_completion_accept; ranker threads a host-supplied lookup closure through, capping the bonus at +50; all three refilter sites swapped over) ✅; (2/3) per-source priority (RawCandidate gains a source: Option<SourceId> field; BufferWordsSource::produce self-tags, host tags the snippet + LSP candidates with constants SNIPPET_SOURCE_ID / LSP_COMPLETION_SOURCE_ID; ranker surface renamed rank_with_frequency -> rank_with_bonus taking a single host-composed closure; three new typed options completion.source.{lsp,snippet,buffer-words}.priority with defaults 200 / 150 / 100 per spec §3.4; App's priority_for_source reads them and the closure adds priority + freq.min(50) for every candidate; unknown-source candidates get 0 priority gracefully) ✅. (3a/3) bare TOML loader infrastructure (lattice-config::loader: walks user config ~/.config/lattice/lattice.toml and project config <root>/.lattice/config.toml, applies scalar leaves via parse_and_set_command, buckets structural namespaces (completion.per-language.*, plugin.*) keyed by full dotted path; warnings for unknown keys / validation rejects / list-at-scalar / read failures, never aborts startup; App.pending_config_structural_sections + take_pending_structural_section / pending_structural_section_paths API for the per- language layer + future plugin host to drain; runtime::run invokes loader between App::new and LSP boot with workspace root walked up from CWD to first .git / .lattice/ marker) ✅; (3b/3) per-language overrides (PerLanguageOverrides { sources, auto_trigger, auto_insert_single, suppress_in } in lattice-completion::insert; spec defaults seeded at App::new via per_language_defaults() -- markdown / text drop LSP for prose, rust enables auto-fire
            • auto-insert-single; TOML drains [completion.per-language.<lang>] structural sections via apply_per_language_toml_overrides with per-key merge onto defaults; effective_completion_for(language) walks per-language -> global option -> spec fallback; enforcement at populate_insert_completion_sync (skip emit for disabled sync sources) and do_lsp_insert_completion_request (short-circuit before the URI lookup); auto_trigger and suppress_in plumbed but not yet enforced -- auto-fire as a feature and tree-sitter scope detection are their own slices; canonical_source_id maps short labels (lsp, snippet, buffer-words, path, tree-sitter) to canonical ids; help refresh in docs/../../user/completion.md lists the built-in defaults table + recognised keys + merge semantics) ✅. 4.2.g.5 complete. 4.2.g.6 (1/2) (tree-sitter local-symbol completion source -- per-language symbols.scm queries in crates/lattice-syntax/queries/{rust,python,javascript}/ capture definition-position identifiers; LangRegistry compiles them via build_config(symbols, ...) extension; Syntax::collect_symbols() walks the cached tree and returns deduped names; new TREE_SITTER_SYMBOL_SOURCE_ID = "gen:tree-sitter-symbol" constant; new typed option completion.source.tree-sitter.priority (default 80 per spec §3.4); App's populate_insert_completion_sync emits tagged candidates after buffer-words / snippets; priority_for_source resolves the new id; both sources emit independently when they overlap (cross-source visual dedup deferred to 4.2.g.7); help adds a dedicated ## Tree-sitter symbols section with per-language capture coverage + ranking-vs-buffer-words explainer) ✅; 4.2.g.6 (2/2) (path completion source -- Syntax::cursor_in_string_scope walks tree-sitter ancestors against a hardcoded string-shape set (string / string_literal / raw_string_literal / etc.); App.completion_in_path_context flag set by do_completion_trigger when the cursor is in a string scope and gen:path is enabled; path-aware anchor walks back over alphanumeric + _-./~+@ until / (the dir/file boundary); populate_path_completion resolves the partial path against the document's parent dir or CWD, walks via std::fs::read_dir capped at 200 entries, skips dotfiles + .git / node_modules / target / dist, emits File / Directory-kind candidates with trailing / for directories; LSP fan-out + non-path sync sources short-circuit in path-completion mode so the popup shows filesystem entries cleanly; new constant PATH_SOURCE_ID = "gen:path"; new typed option completion.source.path.priority (default 90 per spec §3.4); priority_for_source wires the new id; help refresh adds a dedicated ## Path completion section covering scope detection, resolution, ignore set, and popup behaviour) ✅. 4.2.g.6 complete. 4.2.g.7 polish (sliced as independent items): commit chars (Action::CompletionAcceptThenInsert(char) routes every popup-time char through one handler; effective_commit_chars_for unions the focused candidate's per-item LSP commitCharacters with the new typed option completion.extra_commit_chars; popup layer claims unmodified character keys; non-commit chars fall through to plain do_insert_text so the popup refilters as before) ✅; additionalTextEdits coalesce for the LSP snippet accept path (new expand_snippet_with_lsp_edits builds one batch with the auto-import edits + the snippet body's main splice, reverse-sorts by start position so each edit's original- document positions stay valid, applies via apply_edit_batch_blocking so the whole accept lands as ONE undo unit; recovers the snippet's post-batch origin by indexing the applied vec at the main edit's position-after-sort; non-snippet LSP path was already coalesced via apply_lsp_completion_accept's combined Vec) ✅; ghost text for the top-ranked candidate (new typed option completion.ghost_text, default off; new App helper completion_ghost_text_suffix() returns the case-insensitive-prefix suffix when the popup is open with a non-empty query, the top candidate matches as prefix, and we're not in path-context; compose_visible_lines appends a dimmed-italic span on the cursor's row when the cursor is at end-of-line and the helper returns Some) ✅; cross-source visual dedup (new dedup_rendered_by_text helper retains the first occurrence per raw.text after the ranker has sorted descending, wired at all three refilter sites; the surviving row is the highest-ranked one per text, so the buffer-words copy of outer outranks the tree-sitter copy at the spec's 100/80 priority split and wins the popup row; selection / navigation / accept index the deduped vec naturally) ✅; picker-call-site typed routing payload (new RoutingPayload enum + PICKER_ROUTING_KIND_ID const + Picker.routing_meta sidecar Vec; new set_raw_candidates_with_routing(items: Vec<(RawCandidate, RoutingPayload)>) zips producer pairs into the picker, stamping each candidate with Extension { kind_id, payload: index_le_bytes }; routing_for(candidate) decodes the index and returns the typed &RoutingPayload; producers LspInstanceRow::into_candidate_with_routing, LspLocationRow::into_candidate_with_routing, App's raw_buffer_candidates, the LSP-completion picker open, and the code-action picker open all return pairs; do_picker_accept matches on the typed RoutingPayload variant; the string parsers buffer_id_from_text, lsp_key_from_text, jump_target_from_text are gone; RawCandidate.text is now user-facing label everywhere; no UX change -- pure technical-debt cleanup) ✅. 4.2.g.7 complete; 4.2.g done.
            • completionItem/resolve (lazy doc / additional edits) -- shipped as part of 4.2.g.3 + 4.2.g.7.
            • workspaceSymbol/resolve (lazy location) ✅. Client capability now advertises workspace.symbol.resolveSupport.properties = ["location.range"]; Capabilities::workspace_symbol_resolve_provider reads the server's matching flag from workspaceSymbolProvider.resolveProvider. New ServerHandle::workspace_symbol_resolve(symbol, token) client method routes the LSP workspaceSymbol/resolve request. The workspace_symbol response type upgrades from Option<Vec<SymbolInformation>> (legacy-only) to Option<lsp_types::WorkspaceSymbolResponse> -- the spec's Flat | Nested union covering both LSP 3.16 and 3.17+ shapes. App's do_lsp_workspace_symbol_request handles both: Flat(Vec<SymbolInformation>) flows through symbol_information_to_row (range inline); Nested(Vec<WorkspaceSymbol>) flows through the new workspace_symbol_to_row(handle, sym, token) async helper which fires workspaceSymbol/resolve against the originating server when the location came back as WorkspaceLocation (URI only) and uses the resolved range. Eager-resolve at fan-out keeps picker rows uniform (every row is (path, line, col) resolved); the picker's accept path stays unchanged. Servers that don't advertise resolveProvider fall back to (path, 0, 0) so the user can still navigate to the file. Tests: legacy Flat shape round-trips; modern Nested shape with WorkspaceLocation decodes correctly; workspaceSymbol/resolve round-trip upgrades the location.
            • Remaining 4.3:

              • workspace/applyEdit (server-initiated -- inbound channel on the actor that calls back into the App's apply_workspace_edit path) ✅. New lattice-lsp::apply_edit module exposes ApplyEditBus (mpsc Sender side cloned into every actor at spawn) + InboundApplyEdit (server_id + label + edit
                • oneshot for the response) + ApplyEditOutcome (the reply the App writes back). Actor's request branch routes workspace/applyEdit through a spawned task that dispatches via the bus, awaits the App's oneshot, and ferries the LSP ApplyWorkspaceEditResponse back to the wire; other server-initiated requests still resolve inline. Supervisor exposes set_apply_edit_bus for the App; LspSupervisor::new now starts with apply_edit_bus = None so existing tests / mocks that don't care about applyEdit see the pre-4.3 METHOD_NOT_FOUND fallback. App's build_lsp_subsystem creates the bus + receiver pair and stashes the receiver in App.pending_apply_edit_rx; runtime::main_loop invokes App::drain_inbound_apply_edits once per iteration alongside the other LSP drains. The drain flattens each WorkspaceEdit via the existing flatten_workspace_edit (same path :rename uses), applies per-file (active buffer direct, cross-file via :e then apply), echoes a status summary at Info (success) / Warn (partial), and replies via the embedded oneshot with applied: bool + failure_reason. Empty edits reply applied: true with the "empty workspace edit" reason so server logs see the no-op. v1 doesn't track failed_change (atomic-rollback queued for the follow-up apply_workspace_edit_atomic). Tests: lattice-lsp dispatch round-trip + drop-side error + oneshot round-trip; App drain applies edits to active buffer, replies applied=true on empty edit, and is a no-op when the channel is empty.

              4.x edit-path refactor: per-actor DocSync + bus-driven fan-in. Diagnostics testing surfaced that edits were being silently dropped when the App's try_lock-on-supervisor edit path raced the App-spawned debounce task. Architectural fix (chosen against design goals, not ease of impl): move DocSync into the per-server actor (single-writer mirror; no shared mutex), enrich Event::DocumentChanged with path: Option<PathBuf> + inserted_text per AppliedEdit, spawn a per-actor lattice_lsp::fan_in task that subscribes to the bus and forwards each applied edit as ActorCmd::RecordEdit straight into the actor's mailbox. The UI thread now does one EventBus::publish per applied edit (1.9 µs at three subscribers) and never takes the supervisor mutex; the actor coalesces on a 50 ms debounce inside its select! loop. App::lsp_record_edit and the App-side debounce task are gone; supervisor record_edit / flush / flush_all survive as thin proxies for tests + the will-save flush. LspSupervisor gained set_event_bus(Arc<EventBus>) (called once at App startup before any buffer opens) and tracks a per-actor SubscriptionId so shutdown can unsubscribe. New benches in crates/lattice-lsp/benches/lsp.rs: lsp_edit_publish_three_subs, lsp_edit_propagation_publish_to_recv, lsp_didchange_flush_16_edits. New tests in tests/fan_in.rs cover end-to-end didChange after publish, OpenDoc → RecordEdit FIFO, scratch-buffer (no path) skip, unknown-URI warn-and-skip, 50-edit burst coalesce into one didChange, shutdown unsubscribes the bus. Architecture detail in docs/../architecture/lsp-architecture.md §5 + new §11 ("Edit-path architecture") with the bus → fan-in → actor diagram.

              Update this section when picking up the in-flight item.

              4.x audit pass (post-LSP-edit refactor). Once the per-actor DocSync + bus-driven fan-in landed, ran a thorough design- philosophy audit looking for the same class-of-bug elsewhere (UI-thread / async contention, state-bearing best-effort sends, paramount-goal violations). Findings closed in slices 1–6:

              • Slice 1 — retired Arc<tokio::sync::Mutex<LspSupervisor>>. Reads (servers_for, running_actors, ...) go wait-free through ArcSwap<SupervisorSnapshot>; writes route via the supervisor task's mailbox. Closed C2 (Insert-mode trigger probes), H1 (modeline try_lock), H2 (14 supervisor try_lock sites silently dropping work), M5 (App holding the supervisor mutex across .await in drain_pending_lsp_opens).
              • M1 (parallel agent worktree) — EventBus::publish snapshots subscriber list under a brief lock, then dispatches lock-free. No Channel(bounded) subscriber can stall the publisher under the inner lock.
              • Slice 2DiagnosticsLayer swaps inner Mutex for Arc<ArcSwap<DiagnosticsSnapshot>>. Render-frame line_severity calls (~3000/s on the render thread) drop from microseconds + per-call allocation to 25 ns wait-free. Closed C3.
              • Slice 3SyntaxActor. Syntax wraps a SyntaxSnapshot; SyntaxHandle runs reparses on tokio::task::spawn_blocking with bursts coalesced. Renderer / folds / completion read the latest snapshot via ArcSwap -- tree-sitter parses no longer happen on the UI thread (paramount goal #1). Closed C1.
              • Slice 5path_completion_cache keyed by (dir, mtime) so consecutive Insert keystrokes inside string literals don't re-walk the directory; bounded- parallel willSaveWaitUntil (one shared 500ms budget across N servers via tokio::task::JoinSet) caps the total UI-thread save block at 500ms regardless of N. Closed H5, M4. H4's other FS sites (open_lsp_locations_picker reads, :reload-snippets, :e metadata) stay sync as user-command-triggered one-shots; documented as acceptable v1 cost.
              • Slice 6 — document-actor mailbox switches from bounded mpsc::channel(64) to unbounded_channel. RuntimeError::Busy retired entirely (App-side callers were silently discarding it under bursts). Closed H3.
              • Slice 7FrameView per-render-chain snapshot. Reading the architecture honestly: GPUI ships as part of 1.0 (TUI is a renderer peer, not the only target), so "Rust ownership prevents the race today" stops being a valid deferral once a second renderer that runs on a separate thread enters the picture. FrameView::from_app freezes folds, visible_highlights, and show_line_numbers once at chain entry; helpers (compose_visible_lines_inner, render_gutter_for, closed_fold_display_span, buffer_line_to_visible_row_with, cursor_screen_position*, draw_inactive_document) consume &FrameView. Mirror methods (view.fold_start_at_any / fold_start_at / line_inside_closed_fold) read from the snapshot's frozen Arc. Closed M2.
              • Slice 9 — event-driven LSP attach. The LSP open path used to park the UI thread on the initialize round-trip (initial document via runtime::initialize_lsp_blocking, subsequent :e <path> via the pending_lsp_opens-queue + block_on(drain) pattern in the main loop). Two block_on sites both violated paramount goal #4 (asynchronicity). The audit also surfaced a silent-failure bug -- LspSupervisor::spawn used tokio::runtime::Handle::try_current() and dropped cmd_rx if no ambient runtime existed; in production App::new runs before any tokio context, so the supervisor task never spawned and every write returned LspError::ActorGone. Both fixed in this slice:
                • LspSupervisor::spawn now takes an explicit &tokio::runtime::Handle; callers pass runtime::lsp_runtime().handle(). No more try_current() footgun.
                • Buffer-open is event-driven: App::new and App::do_edit set BufferId → Uri eagerly and publish Event::DocumentOpened { id, path, version, text } on the bus. The new lattice_lsp::attach_driver module subscribes on the LSP runtime, runs a serial recv → supervisor.open_buffer.await loop, and logs failures. UI thread never parks. Single path for initial + subsequent opens.
                • Removed: App::pending_lsp_opens, App::queue_lsp_open, App::drain_pending_lsp_opens, App::initialize_lsp, runtime::initialize_lsp_blocking, runtime::drain_pending_lsp_opens_blocking, and the main-loop drain step. Net diff is removal-heavy.
                • Closes the no-rust-analyzer-attach regression that surfaced as "server actor is no longer running" on every editor launch.

              Deferred with rationale:

              • M3 (input.rs trie-driven dispatch)input.rs is 4365 lines of hand-rolled KeyCode matching; plugins can't bind chords (paramount goal #3 violation: "the grammar IS the public command API"). Real present-day capability gap, scoped as Slice 8 of this audit pass (in progress). The fix replaces the hand-rolled match with a trie consuming the KeymapEntry { chord, command_invocation } table from keymap.rs; metadata surface in keymap.rs becomes the source of truth; input.rs becomes a chord-string normaliser; plugins gain a structured extension point.

              docs/benchmarks.md got the new perf rows (lsp_diagnostics_line_severity_wait_free at 25ns, the existing edit-path benches). Tests stayed green at every slice boundary.

              1. Phase 4: LSP — diagnostics, completion, hover, go-to-definition, references. The cancellation-token plumbing is in place (dispatch_with_cancel + cooperative search cancellation), so LSP request cancellation hooks into existing seams; the remaining work is the LSP client (tower-lsp or hand-rolled) + per-server shims.

              2. Computed folds (per docs/../../user/folding.md) — ✅ done for all v1 providers except tree-sitter syntax queries. Manual zf / zo / zc / za / zR / zM / zd / zj / zk, plus the new zi (:set foldenable!). Two computed providers: compute_indent_folds (universal) and compute_markdown_folds (ATX heading nesting, code-fence aware for both ``` and ~~~). :set foldmethod=manual|indent|markdown|syntax parses; Syntax is a v1 cascade (markdown for .md, indent otherwise) until the tree-sitter scope-query provider lands.

                Beyond storage, the user-facing pieces from docs/../../user/folding.md: identity-hash recompute (heading text + indent depth) preserves closed-state across edits in unrelated sections; closed folds render heading-preserved with a dim ┄ N lines folded suffix (no +--- N lines --- line replacement); gutter glyphs ▾ open / ▸ closed; dd / yy / cc / >> on a closed fold expand to the full fold range as a single undo unit; jump-class motions (search, gg / G, H / M / L, marks, Ctrl-O / Ctrl-I, %) auto- open the destination fold; :set foldenable + zi short-circuit every fold-aware path while preserving closed-state.

                Tree-sitter-driven folds (function bodies, classes, blocks via folds.scm) remain queued. The Fold data type is shared, so a tree-sitter provider drops into the existing recompute / identity / render plumbing without a redesign.

              3. :set option=value + typed options (§5.12) ✅ done — now in its own renderer-agnostic crate. The lattice-config crate owns the option machinery: an OptionType trait (with built-in impls for bool, i64, String); Option<T> whose value cell is an arc_swap::ArcSwap<T> for wait-free hot-path reads; an ErasedOption trait + ConfigRegistry backing both typed OptionHandle<T> access and by-name (:set foo=bar) access; the :set syntax parser; and an OptionsGenerator that the completion pipeline picks up via the gen:options source. register_core_options(&registry) → CoreOptions registers the nine renderer-agnostic options (number, relativenumber, wrap, ignorecase, tabstop, foldenable, foldmethod, scrolloff, completion.auto_insert_single); each renderer registers its own UI-specific options through the same register API and gets its own typed-handle struct back (lattice-ui-tui ships register_tui_options → TuiOptions for ui.dim_inactive, ui.separator, ui.separator_color, ui.statusline_active_fg, ui.statusline_inactive_fg). FoldMethod lives in lattice-core::folding (any renderer reads it the same way); the OptionType for FoldMethod impl lives in lattice-config::domain to keep core's dep direction inward. :set name=value echoes are byte-identical to the pre-migration wording, including all error messages (E518: Unknown option, E474: not a boolean option, tabstop out of range [1, 32]: N). Multi-option :set syntax (:set ic hls scs) still deferred.

                App integration: App.config: Arc<ConfigRegistry> plus typed handle structs (core_options, tui_options) replace the previous duplicated App.foldmethod / App.show_line_numbers / etc. fields. Read-side accessors (app.foldmethod(), app.tabstop(), ...) wrap the indirection so call sites read like a field access. do_set calls config.parse_and_set_command(...) and runs apply_post_set for cascade side effects: relativenumbernumber=true, foldmethodrecompute_folds(), every ui.*sync_theme_from_config() to refresh the cached Theme Style projections.

                §5.12 amendment landed in ../architecture/design.md (no plugin code yet). Two-layer config codified: ~/.config/lattice/options.toml for static data; ~/.config/lattice/init.rs compiled to WASM Component, loaded by the §5.5 plugin host with a boot capability, for programmable config. Auto-build on first boot (cargo-component under ~/.config/lattice/cache/); cache by source hash + lattice version + WIT revision. lattice config build exists as a diagnostic CLI. Project-local code-config deferred behind a future per-directory trust prompt; project-local options.toml supported. Implementation depends on Phase 7 (plugin host); lattice-config-api crate added to the project layout as the WIT-bindings reexport user init.rs consumes.

              3a. §5.2.1 closure: kind-prefix form on : ✅ done. The legacy parser-kind rejection is gone; every command (motion, operator, text-object, ex-command, plugin contribution) is reachable from : via :<kind> <name> syntax. Three reserved kind words on : (motion, operator, text-object); ex-commands keep their bare alias surface. Operator targets resolve via implicit-namespace lookup. ../architecture/design.md §2.2 codifies the no-function-call-syntax-on-: invariant; §5.2.1 specifies the kind-prefix grammar. See crates/lattice-ui-tui/src/excommand.rs::parse_kind_prefixed. 4. Multi-buffer foundations (§5.9) — the trigger for HelpDisplayMode beyond Popup. Until this lands, all introspection is overlay-rendered.

              • B.1.a buffer abstraction + active-buffer routing ✅ done. BufferKind { Document, Help } + BufferId newtype; App::active_buffer decides which cursor a motion / page / scroll / <C-o> / <C-i> action mutates. Help routes through the same translate_normal chord grammar as document buffers; only three buffer-local bindings differ (Esc / q dismiss, <CR> follows the link under the cursor). The unified position-history ring carries (buffer, buffer_id) so jump-list walks switch active_buffer cleanly when crossing buffer boundaries. lattice_grammar::execute_motion_only exposes a read-only motion dispatch path that resolves a CommandInvocation against a bare Buffer -- no Document / undo / selections required, suitable for help and (later) file-tree / outline / diagnostics views.
              • B.1.b pane tree + splits ✅ done. Recursive binary-split PaneTree with <C-w>{s,v,c,q,h,j,k,l,w,W} chord grammar. Each leaf carries per-pane viewport stash (cursor + scroll); the active pane's stash is hot-loaded into App::cursor / App::scroll so motion code stays unchanged. Active-pane switches snapshot back into the source pane's stash and load from the destination's. Geometry-aware navigation (<C-w>{h,j,k,l}) walks the spatial neighbour computed from compute_rects. Inactive-pane rendering is a placeholder until B.1.c brings meaningfully distinct buffer content.
              • B.1.c multiple Document buffers ✅ done. Replaced by the unified registry below; original implementation used a dedicated documents: HashMap<BufferId, DocumentEntry>.
              • B.1.d buffer-as-content kinds: file-tree ✅ done. New BufferKind::FileTree variant + FileTreeBuffer (rope- backed, same shape as HelpBuffer). :Tree [path] opens a tree buffer rooted at path (or the document's parent dir / cwd); same path de-dups (already-open trees are switched to, not duplicated). Multiple trees coexist (one per distinct root). :TreeClose removes from the registry. Standard motions route via the same active-buffer dispatch as Help. <CR> on a directory toggles expansion; on a file opens it via the standard :e FILE path. Outline + diagnostics panels queue behind their own integrations.
              • Unified BufferRegistry ✅ done. Documents and file trees live in a single keyspace under App.buffers (HashMap<BufferId, BufferEntry> with BufferData::Document | FileTree discriminant). :bn / :bp / :ls / :bd / :b N operate on the registry uniformly -- cycling between a document and a tree feels the same as cycling between two documents. Each entry carries BufferFlags { listed, hidden }; unlisted buffers are skipped by :bn / :bp. :e folder defers to :Tree folder (vim's :Explore semantics). Help stays overlay-rendered for now -- moving it into the registry is a follow-up that doesn't require structural change. DocumentEntry stashes per-buffer hot-path state (syntax tree, fold list, last-parsed version) when a buffer leaves active; a single App::activate_buffer_state lifecycle hook fires on every transition into a document buffer (both :e <new> and :b N paths), reparsing if needed and seeding folds against the active foldmethod so users no longer have to reach for <C-l> after switching files. New buffer-level state plugs into the same hook -- no per-option fixups across the activation paths.
              • Pane visuals ✅ done. Each pane gets a vim-style status line (active reverse-videoed via theme, inactive dim); separator drawn between vertically split panes. Inactive Document panes keep their tree-sitter syntax highlights (refreshed lazily by App::refresh_pane_highlights); a Theme::inactive_pane_overlay modifier (default DIM) layers on top so focus stays unambiguous without losing color. Customizable via :set ui.dim_inactive, ui.separator, ui.separator_color, ui.statusline_active_fg, ui.statusline_inactive_fg.
              1. Hover popup + inline completion popup polish — completion popup is wired (vertico-style); hover popup scaffolding ✅ done. New HoverPopup type with markdown body + buffer-position anchor; markdown highlights computed via the shared LangRegistry. The renderer floats the popup near the cursor (below if room, above otherwise). :hover [text] opens manually for now (Phase 4 LSP will source text from textDocument/hover); :HoverClose dismisses. 5b. Help topic surface (:help) ✅ done. lattice-ui-tui::help_topics defines a HelpTopicRegistry keyed by name; bodies are either Static(&'static str) (built-ins are sourced from docs/user/*.md via include_str! so the binary is self-contained) or Dynamic(closure) -- the seam for LSP / plugin / config- supplied topics. :help with no arg opens the registry's index topic (the README content); :help <topic> opens the named topic; :h is an alias. <Tab> enumerates topics via a new gen:help-topics completion source. :describe-* appends See also: [topic](help:topic) cross-links when a topic's related_command_patterns substring-matches the described command. New HelpLinkTarget::Topic(name) variant
                • help: URL scheme so topic links are first-class everywhere a help body can render.
              2. Help major mode + tree-sitter grammar — done; see docs/user/help-mode.md and the help-table row above. This numbered list predates Phase 8 landing; left as historical record, corrected in place rather than deleted.
              3. Veto-class hooks + actor event publish (§5.10.2 / §5.2.1) — observation-only event bus is in place; pre-mutation hooks (BeforeSave, BeforeQuit) need the mutation/abort return path. Actor wiring to publish DocumentChanged / SelectionsChanged / ModalModeChanged events. Unblocks autocmds.
              4. Per-LatencyClass deadline timers — Reflex commands observe a 2 ms deadline, Display commands 10 ms, both via the cancellation token already plumbed.
              5. Bench regression gate — needs a stable runner (self-hosted or bencher.dev); shared GitHub runners have ~20% bench variance that dwarfs a 10% regression signal.
              6. Render-hot-path alloc discipline — dhat-based assertion that steady-state frames produce no allocations.

              Carried over from archived slice plans

              Work a completed plan recorded but did not own. Kept here so archiving a plan does not bury it; each line names where the detail lives.

              • Off-renderer effects by allowlist ✅ (from project-commands PC.14 and OR.16's report). The off-renderer paths still apply the four renderer-owned effects they always did; anything else is now QUEUED on Editor::pending_renderer_effects and applied by whichever peer draws next, through its own arms. Not run_tick_pending returning effects, and not a second pass through handle_effect: every effect on these paths has already been applied once (apply_effect_host applies and then records it in out.effects), so re-applying wrote a capture's file twice — caught by async_picker_accept_applies_open_buffer_at.
              • The org scan's time ratchet is a printing probe, not an assertion (from org-agenda OA.0a, see slice-plans/archive/org-agenda.md). All four tests in lattice-org-plugin/tests/org_agenda_hang.rs are #[ignore]d, scan_time_versus_size among them. OA.0a named making it an assertion as its follow-up "so the ratchet holds from the org side too". The authoritative ratchet is the bench, so this is a second line of defence rather than the only one — which is why the slice closed without it.
              • Vim-parity follow-ups (from visual-motions, see slice-plans/archive/visual-motions.md):
                • :set scroll=N is still a GLOBAL option (vim's is window-local). The per-window part — the value a count to <C-d> / <C-u> sets — is on PaneState::scroll_lines since 2026-09-18, so two windows scroll by their own amounts; the option is the default a window with no count uses. A real window-local option LAYER is still unbuilt (VM.3j-3).

              §15 open questions still load-bearing

              These are tracked in ../architecture/design.md §15. Items the implementation has resolved are crossed out there. Items that influence active tasks:

              • §15:18 Folds storage / interaction — feeds the Computed folds task above.
              • §15:19 Replace mode dispatch (resolved by current Replace impl).
              • §15:20 Live evaluation (deferred per §10).
              • §15:21 File watcher / auto-revert — unaddressed.
              • §15:22 Bookmarks / cross-file marks — current marks are buffer-local.
              • §15:23 Function rebinding / advice — unaddressed.
              • §15:24 Narrow-to-region — N.1 in design (2026-06-10); see narrow-mode section below.
              • §15:25 Snippets / abbrev — unaddressed.
              • §15:26 Frames (multi-OS-window) — unaddressed.
              • §15:27 Session save / restore — unaddressed.

              Test counts (snapshot)

              Coverage by crate. Crates marked (2026-06-29) were re-verified this session; the rest are from the prior snapshot and want a re-run.

              CrateTests
              lattice-protocol39
              lattice-core (incl. integration)128
              lattice-grammar191
              lattice-keymap99 (2026-06-16)
              lattice-completion124
              lattice-config118
              lattice-syntax82
              lattice-theme53 (2026-06-29)
              lattice-runtime43
              lattice-lsp183
              lattice-picker48 (2026-06-29)
              lattice-ui-tui1507 (2026-06-29)
              lattice-host654 (2026-06-29)
              lattice-ui-gpui25 default · 114 --features window (2026-06-29)

              Plus criterion benches for hot paths (search, buffer, motions, operators, runtime actor) — see docs/benchmarks.md for the latest numbers.


              buffer-local options (2026-06-08)

              Design fragment: ../architecture/buffer-local-options.md. Slice plan: archive/buffer-local-options.md.

              Every option is potentially buffer-local. :setlocal foo=bar writes layer 2 of the existing resolver stack (the buffer_local_overrides field already on Editor); :set continues to write the global layer. OptionOrigin tracks where each effective value came from (BL.2).

              • BL.1 (2026-06-08) — Write path: ErasedOption::parse_to_erased (parses without writing global storage) + ConfigError::QueryNotAllowed + ConfigRegistry::parse_for_buffer_local (returns (TypeId, Arc<dyn Any>, canonical_name)) + ParsedSet::Reset variant (name& / bare &) + Editor::do_set_local + Editor::do_set_local_write (inner write path) + Effect::SetLocalOption + ex:setlocal registered in grammar + ALIAS_TABLE entries setlocal / sl. 4 tests green: write override for active buffer, per-buffer independence, name& clears one override, & clears all.
              • BL.2 (2026-06-08) — OptionOrigin enum (Default, GlobalConfig, BufferLocal, ModeContribution { mode_id }) in lattice-config/origin.rs + parallel origins map in ResolvedOptions with get_origin<T>() / get_origin_for_typeid() / get_erased() APIs + insert_erased_with_origin + Resolver::resolve_into_with_origins (origin-tagged layer list; resolve_into delegates) + recompute_options_for_buffer tags every layer with its origin + ErasedOption::format_erased_value (formats an erased Arc) + ConfigRegistry::type_id_for_name exposed pub + :setlocal name? fixed to use TypeId matching via type_id_for_name + format_erased_value + Effect::SetGlobalOption + ex:setglobal (apply_set_global) + aliases setglobal / sg + Editor::do_set_global + OR-pattern arms in both renderers. 3 tests: set_global_writes_registry_not_local_overrides, resolved_origin_is_buffer_local_after_set_local, resolved_origin_is_global_config_without_local_override.
              • BL.3 (2026-06-08) — build_describe_buffer_content gains a ## Buffer-local options (N overrides) section: iterates buffer_local_overrides[active_buffer], resolves TypeId → name via OPTION_DECLS, formats via ErasedOption::format_erased_value, sorted alphabetically. build_list_options_content shows per-buffer effective value + origin when the resolved value differs from global (buffer-local or mode-contribution). 4 tests green.

              narrow-mode N.1 + tree-sitter text objects (✅ complete, 2026-06-10)

              Design fragments: ../architecture/narrow-mode.md (the zn operator + the one-excerpt view) and ../architecture/tree-sitter-text-objects.md (the af/ac/aC/… objects). Slice plan: slice-plans/archive/narrow-mode.md.

              Narrow mode renders a one-excerpt MultibufferDocumentHandle over an arbitrary line range. No new BufferKind — the narrow view IS a multibuffer and reuses every M-series primitive (M.3 edit propagation, M.4 live updates, M.10.1 ActionHandlerRegistry, K.4.7 syntax). The primary entry is a zn narrow operator (operator-pending; composes with any motion / text object), provider-owned, emitting the generic Effect::Action(AppEffect) — zero new host variants. Tree-sitter text objects (af/if, ac/ic, aa/ia, al/il, aC/iC) are a separate, universal grammar feature owned by lattice-syntax, registered through the existing register_text_object API plus one new ScopeResolver seam in lattice-grammar; they compose with every operator (daf, vic, znaf), not just narrow.

              All slices landed 2026-06-10 (statuses live in the slice plan; this table mirrors the final state):

              SliceTitleStatus
              N.1.0textobjects.scm (.outer) + scope_at_cursor (lattice-syntax)
              N.1.1create_narrow_view + NarrowMinorMode + :narrow {range} + :widen
              N.1.2:narrow from Visual selection / cursor paragraph
              N.1.3The zn narrow operator (operator-pending; composes with any motion/object)
              N.1.4Tree-sitter text objects as first-class grammar objects (ScopeResolver seam + af/ac/aa/al/aC + .inner)
              N.1.5Transparent stacked narrow — one-hop invariant to RopeDocumentHandle
              N.1.6Comment text object (aC/iC) — commentstring-driven + TextObjectEnv seam

              Follow-on keymap work (2026-06-16). Surfacing zn in Visual mode led to three keymap-architecture landings (design in ../architecture/keymap-architecture.md):

              • Operators act on the Visual selection BY DESIGN (fa372b8f). An operator's selection-operability is intrinsic, generated once per operator by keymap_normal::register_operator_bindings (Normal op-pending family + Visual op.with_range(Range::Selection)), not a per-operator Visual binding. The hand-rolled Visual operator_table is gone; builtin d/c/y/>/<, case gU/gu/g~, and contributed zn all get it uniformly — so a Visual selection + zn narrows the selection with zero narrow-specific wiring (the :'<,'>narrow path from 852e95e1 still works too). §7.2 upgrade 3.
              • Multi-mode binding API (B-field) (9b79b9da) for NON-operator chords that mean the same thing across modes: KeymapHandle::bind_modes, Keymap::bind_chord_modes, and keymap_entry! { mode: [Normal, Visual] }. KeymapEntry.modemodes: &[BindingMode] (single-mode sugars to a one-element slice; existing call sites unchanged); the translation pass fans one entry into one binding per mode. §3.5 + §5.6.
              • Single keymap_entry! definition (a7c7799b): the duplicate copy in lattice-mode is gone — it re-exports the canonical lattice-keymap macro (pub use lattice_keymap::keymap_entry). Supersedes K.2.4.A.0.1's move-to-lattice-mode note. $crate resolves to lattice_keymap transitively, so mode crates need no direct dep.

              See also: slice-plans/multibuffer-providers.md for the remaining consumer providers (A.1–A.21 catalog, formerly archived in multibuffer-views.md, now active).


              host ↔ provider boundary + inversion (analysed, 📝 DEFERRED to post-Phase-7, 2026-06-17)

              Design fragment: ../architecture/host-provider-boundary.md. Slice plan: slice-plans/host-provider-inversion.md. Motivation: the recurring-drift cost analysed in ../architecture/comparison-zed.md §5.

              The boundary decision (lasting). Drawn on merit — "can the editor be itself without this, and is it meant to be replaceable?" — not a reflexive "everything is a provider":

              • Core (host owns, holds state directly, no indirection, no perf compromise): text/grammar/rendering substrate + the registries, LSP, diff, terminal (the SyntheticDoc seam), snippet (the Editor holds its registry/policy/completion-meta), multibuffer-substrate, completion/picker, syntax, folds, messages, help. Native-core like the grammar — the plugin seam lets others add intelligence; the built-in stays native.
              • Feature-buffers (the only genuine extraction candidates): oil, file-tree (zero Editor-struct coupling; pure run_*_invocation dispatch). (narrow/search already live in lattice-multibuffer/providers.)

              Why DEFERRED, not done. The full dependency inversion was scoped and the extraction's cost surfaced via a primitive audit: relocating ~3 tiny first-party handlers requires exposing a ~10-method generic ActionContext facade — several primitives substantial (do_edit, do_goto_tab). That facade IS the plugin API, which must be designed against real plugins at the WASM-host phase (Phase 7), not retrofitted from a file browser. The "host stays thin" drift it would remove is cosmetic: the load-bearing classes are already sealed by construction (the keymap_entry! macro carries no layer; PushLayerKind has no Builtin; kind-branching is aligned-by-fallback; provider behaviour routes via ActionId). A "sealed constructors" mechanism was also considered and dropped — it did not survive the code (sealing Document would be anti-extensibility, since providers/plugins must implement it). The HPI.1 ActionContext primitive inventory is captured in the slice plan as the Phase-7 starting point.

              Net: the boundary decision is recorded (the WASM host will need it); the execution waits for Phase 7 where the ActionContext/plugin API has real requirements. The comparison-zed.md "fragile drift" claim was corrected to the accurate bounded-and-structurally-sealed picture.


              Contributable registries (CR.1–CR.5 ✅ complete, 2026-08-22)

              Design: ../architecture/contributable-registries.md. Slice plan: slice-plans/archive/contributable-registries.md.

              Closes HD.6 and the plugin half of DB.8, which were two deferred slices blocked on one missing thing: HelpTopicRegistry and DashboardRegistry were both built once at boot and immutable afterwards, so a plugin could ship neither a :help page nor a launch-page section. Both docstrings already promised the seam and neither was reachable — a paramount-#2 hole in two of the surfaces a plugin most obviously wants.

              One mechanism, two consumers: Arc<ArcSwap<T>> with teardown by provenance, the idiom the tree already carried four times over.

              SliceTitleStatus
              CR.1HelpTopicRegistryHandle — topics behind Arc, RCU handle, Phase-A service
              CR.2DashboardRegistryHandle — RCU handle, last-wins resolution, shadow-restoring unload
              CR.3the help WIT seam — bodies include_str!'d into the component, namespaced topics
              CR.4the dashboard WIT seam — live budgeted render, WasmDashboardSection
              CR.5user docs, benches, ledger, source-plan closeout

              The two seams differ in shape, deliberately. A help topic is data — its body does not change between load and read, so the guest hands over a string and is dropped; reading :help never touches wasm. A dashboard section is a function of a DashboardCtx (pane width, ui.nerd_fonts, version) that the guest cannot know at load and that DB.6 exists to change at runtime, so the guest stays instantiated and the host calls it per compose. Making both data would freeze sections blind to the icon palette; making both live would keep a wasmtime::Store alive per docs plugin for nothing.

              Retired: the 2026-07-29 runtime-doc-directory plan, as the plugin mechanism. It needed plugins to copy markdown into a shared directory at install time and separated docs from the artefact owning them. The resolution chain survives in embedded-docs-budget.md as a builtin-volume lever only, and is weaker for having lost its second justification.

              Namespacing differs too, and for a reason. Help topics are auto-namespaced by plugin id (so a plugin cannot shadow :help buffers), with one refinement: a topic named after the plugin, or unnamed, lands at the bare id — :help fugitive, not :help fugitive.fugitive. Dashboard section ids are not namespaced, because replacing a builtin section is a stated DB.8 capability. That puts the weight on unload, which CR.2's shadow stack carries: the registry appends rather than overwrites, so unregister_plugin is a retain and the displaced builtin resurfaces with no restore bookkeeping.

              Two bugs the bench found, both the same shape and one of them pre-existing: fuel is a per-call budget, and a seam called repeatedly must re-arm per call. CR.4's render and CM.6b's WasmErrorParser::feed both armed once at instantiate, so both worked for a while and then trapped permanently — the dashboard after 1173 composes, the parser after ~150 k build lines. Fixed in 5cb57b69 / b2a4e992. See the CR.4 section of benchmarks.md; the tell was a render benching at 9.37 ns, which was a poisoned early-return rather than a wasm call.

              Dashboard (DB.1–DB.7 ✅ complete; DB.8 plugin half ✅ 2026-08-22, theme-remap half open)

              Design: ../architecture/dashboard.md. Slice plan: slice-plans/dashboard.md.

              The *dashboard* launch page shown when the editor opens with no file argument (dashboard.enabled, default on) or on demand via :dashboard. A BufferKind::Dashboard synthetic buffer — read-only, help-mode-style link-follow reused via buffer-kind grouping (no help-mode dependency), no renderer kind-branch beyond the existing Help-grouped gates. Content is config-selected + ordered built-in sections (dashboard.sections) composed through an extensible DashboardRegistry (DB.8 adds plugin sections), or a user file that fully replaces composition (dashboard.source). Branding is a VirtualRowProvider terminal-art block + wordmark, colour-resolved from dashboard.* theme elements; GPUI additionally custom-paints a 2-D lockup (mark top-aligned to the wordmark's cap-height) rather than the TUI's block-art rows. Startup activation and recompose (on dashboard.sections / dashboard.source / ui.nerd_fonts change, gated on already being open) are both mode-owned subscriptions emitting the same Effect::OpenDashboard :dashboard uses — zero new host Action/Effect variants across the whole feature.

              SliceTitleStatus
              DB.1crate + registry + fragment + config
              DB.2*dashboard* buffer + dashboard-mode + :dashboard
              DB.3dashboard.* theme elements
              DB.4branding block (art, colour, gutter-centring, help-mode, cursorline)
              DB.4-cleanuprevert redundant VirtualRowAlign (gutter supersedes it)
              DB.4-gpuiper-token virtual-row scaling (F.3) + scaled wordmark; side-by-side custom-paint lockup deferred (post-1.0 w/ image)
              DB.5startup gating + mode-owned trigger (Startup typed event, ConfigRegistry Phase-A hoist)
              DB.6full override + recompose triggers (dashboard.source, Event::OptionChanged subscription)
              DB.7benches + ledger (this entry)
              DB.8plugin sections (→ CR.2/CR.4) + body custom roles (→ theme-system.md)🚧 plugin half ✅ 2026-08-22; theme-remap half still open

              DB.7 bench coverage (design §13). The dashboard composes once at creation, never per keystroke, so there is no keystroke→glyph bench. crates/lattice-host/benches/dashboard.rs covers the two design-mandated assertions instead: dashboard_creation (cold compose+seed threshold) and dashboard_idle_tick (idle-frame cost). The idle-frame correctness half — not just fast, but literally zero recompose — is pinned as a regression test (dashboard_idle_ticks_do_not_recompose in tests/dashboard.rs) asserting the document version doesn't advance across idle ticks, the same "enforced, not asserted" bar the B2.3 bug story set. See BENCHMARKS.md for recorded numbers.


              Tree-sitter structural motions (TSM.0–TSM.5 ✅ complete, 2026-07-08)

              Design: ../architecture/treesitter-motions.md. Slice plan: slice-plans/archive/treesitter-motions.md.

              Sixteen tree-sitter-backed next/previous structural motions (]f [f ]F [F, ]c [c ]C [C, ]a [a ]A [A, ]l [l ]L [L) jumping between functions / classes / parameters / loops, composing with every operator, count, and Visual selection like any other motion. lattice_grammar::ScopeResolver grew a scope_toward(...) navigation query alongside its existing text-object resolution, threaded into MotionContext exactly as the text objects already were — lattice-grammar stays tree-sitter-free (the trait lives there), lattice-syntax owns the tree walk (SyntaxSnapshot:: scope_toward) and the 16 MotionSpecs (register_syntax_motions, SyntaxMotionIds). No new .scm files: reuses the existing @function.outer / @class.outer / @parameter.outer / @loop.outer captures. Motions register at KeymapLayer::Builtin (universal vim grammar, like the tree-sitter text objects), not a MinorMode; the host only wires chord → id.

              SliceTitleStatus
              TSM.0design fragment + slice plan
              TSM.1grammar seam — scope_toward + MotionContext.scope_resolver
              TSM.2SyntaxSnapshot::scope_toward real tree walk
              TSM.3register_syntax_motions (16 MotionSpecs)
              TSM.4host wiring — boot registration + 16 chord bindings
              TSM.5bench + docs/ledger finalization (this entry)

              TSM.5 bench coverage (paramount #1). scope_toward runs on the core/actor thread on a deliberate keypress, never in Render::render, never per-frame — bounded by QueryCursor::set_byte_range to the relevant half of the file (Forward scans [cursor, EOF), Backward scans [0, cursor)). crates/lattice-syntax/benches/scope_toward.rs isolates that tree-walk cost on a 2000-function synthetic Rust file, querying @function.outer from the file midpoint in both directions. See BENCHMARKS.md (TSM.5 entry) for recorded numbers and the byte-range-restriction rationale.


              Agent integration (Claude Code + opencode, ✅ core complete, 2026-07-10)

              Two coding agents over two protocols, one shared capability surface. Design: ../architecture/agent-integration.md (the EditorAccess port + why one subsystem serves both), ../architecture/ai-agent-protocol.md (ACP wire contract / auth / trace logs), ../architecture/agent-ui.md (the conversation UI). Slice plans archived under slice-plans/archive/{agent-integration,agent-ui,ai-agent-protocol}.md.

              AreaStatusNotes
              Claude Code (MCP)claude CLI attaches over a loopback WebSocket; runs its TUI in a terminal buffer; edits via openDiff. lattice-ai/mcp. User doc: ../../user/claude-code.md.
              opencode — v1 (terminal TUI):opencode runs opencode's native TUI in a terminal buffer (Effect::SpawnTerminal + opencode-mode minor over terminal-mode), the same topology as Claude Code. opencode's TUI provides readline / / commands / model switching / history / edit review; lattice reimplements none of it. lattice-ai/opencode (always compiled). User doc: ../../user/opencode.md.
              opencode — ACP buffer conversation (:opencode-acp)✅ keptThe AU-1…AU-5 headless-ACP path, repositioned as the alternative: lattice spawns opencode acp, folds session/update into a Conversation store, projects it into the *ai:opencode* Document (ai-conversation mode, comint editable-tail via Mode::editable_tail() + the read-only edit gate), <C-c> interrupt (session/cancel), user turn folded in. Its distinguishing win: lattice-owned diff review. Kept for the future IDE-native-review direction. lattice-ai/acp (feature acp). Design: agent-ui.md.
              ACP diff review + approval (AU-4a)(:opencode-acp) agent→client session/request_permission → supervisor; reads auto-run, file-edits → review_diff (shared with MCP openDiff) → verdict-gated. Fail closed: un-reviewable mutating ops denied in review mode (a background security review flagged the original auto-allow as a permission bypass).
              ACP trust mode (AU-5)(:opencode-acp) per-session auto_accept (default off = review); <C-t> toggles + echoes; permission tasks read an Arc<AtomicBool> live; resets on each Start.
              Split conversation vs trace(:opencode-acp) conversation sources → Conversation store → *ai:opencode*; trace → AiLogger ring → :ai-log (the :lsp-log analog). :ai-log / AiLogger are kept as generic infra even for the terminal path (no producer there yet).

              Deferred / follow-ups (not blocking; tracked here since the slice plans are archived):

              • opencode terminal path — IDE-native edit review. For v1 the terminal :opencode reviews edits in opencode's own TUI (Plan/Build + /undo); lattice does not intercept them. A native integration (opencode's edits opening in lattice's diff view while its TUI runs) is the planned follow-up, and is the reason the :opencode-acp machinery (which already does lattice-owned diff review) is kept rather than deleted. Whether it's built by co-attaching ACP to the TUI's session, or an MCP-style callback like Claude Code, is an open spike.
              • The items below are :opencode-acp (ACP buffer path) follow-ups:
              • AU-4b — [diff] Edit-block reflection. Deferred as redundant: an agent file-edit already streams as a Block::ToolCall, so a separate Block::Edit double-represents it. Revisit by annotating the ToolCall block if the explicit accept/reject verdict (distinct from execution status) or a diff-reopen affordance proves worth it. Block::Edit / EditStatus stay in the model, unused.
              • Decoration-based in-place tool-call status + reasoning folds. The projection is plain text today (turn headers + ▸ … [running] lines, drain replaces only the transcript zone). Decoration/virtual-row status is deferred polish.
              • Trust-mode headerline indicator. AiState already republishes auto_accept; a review/auto modeline/headerline element can read it with no supervisor change. The <C-t> echo is the visible reflection today.
              • Command-confirmation surface. Non-file mutating operations are denied in review mode (fail closed); a confirmation prompt (so they can be reviewed rather than only trust-allowed) is the real fix.
              • Other ACP providers + :ai-send context push + in-protocol auth (the original AI-4 slice). Not started.

              compilation-mode + error-list (✅ core complete, 2026-07-24)

              Design fragments: ../architecture/compilation-mode.md (the runner + *compilation* / *problems*) and ../architecture/error-list.md (the core error-list / quickfix substrate). Slice plan + status: slice-plans/compilation-mode.md. Native built-in (lattice-compilation crate + SubsystemBoot install seam); Option C — streaming buffer primary, error-list navigation, *problems* multibuffer secondary.

              What's done:

              • Streaming *compilation* buffer:compile <cmd> / :recompile / :make run a shell command off the UI thread (spawn_blocking child + two dedicated pipe-reader threads); output streams line-by-line into a read-only synthetic Document created through the mode-owned ModeActivator::ensure_named_document seam. compilation-mode (major, ReadOnly + NoFile) owns the keymap, drain, parser wiring, and handler bodies. Kill-prior-on-:recompile. On Unix the child runs in its own process group (pre_exec + setpgid(0,0); libc FFI gated behind #[cfg(unix)], #![allow(unsafe_code)] scoped to this crate) so :compilation-kill terminates the shell AND all pipeline grandchildren atomically via killpg(-pgid, SIGKILL). On Windows TerminateProcess handles the single child. Exit summary line appended.
              • Compilation headerline — mode-owned sticky view-header virtual row (twin of project-search's): icon-led format — ⟳ "cargo build" … (running), ✔ "cargo build" ok (clean), ✗ "cargo build" 3e 2w (errors), ■ "cargo build" killed (killed). Killed state is detected from "Compilation terminated" in the Finished summary. Five theme-resolved colours (compilation.headerline.command / in_progress / success / failure / dim). Drain owns the state; Reset clears killed on new runs.
              • 4-parser tool-agnostic error listParserRegistry::with_builtins registers CargoRustc (multi-line) + GnuStyle + TestPanic (Rust thread '…' panicked at …) + General (catch-all file:line:col anywhere, is_file_like-gated, registered last), de-duped by (path, line, col). Both stdout and stderr are parsed (test panics print on stdout), merged through a shared Arc<Mutex<Vec<ErrorEntry>>>InboundBusAppEffect::SetErrorList. Phase 7 opens WASM parser contribution.
              • Navigation — the error list is core Editor state (like the jump ring), walked by generic :next-error family + vim :c* aliases + Builtin ]qq/[qq/]qf/[qf/]Q/[Q from any buffer; survives closing *compilation*; no diagnostic fallback. <CR> in *compilation* parses the cursor line (interleaving-proof) and jumps + syncs the index. <C-c> (mode-dispatchable chord) → :compilation-killCompilationService::kill().
              • Decorations — severity gutter marks (native render_stateDecorationCtxMode::gutter_decorations path, shared with LSP/diff)
                • location-line background tint (compilation.location theme element); both produced off-thread in the drain.
              • *problems* view:problems / :copen groups error-list entries as anchored, editable source excerpts (multibuffer search-provider template); :error-list / :cl is the fuzzy picker. Third view of the one list (step / pick / group). Generic multibuffer <CR> jump-to-source
                • excerpt motions apply.

              Both former deferrals landed 2026-08-21: CM.5 (ANSI-SGR → captured-line decorations) and CM.6/CM.6b (WASM-contributable parsers — the error-parser seam plus its live wiring, a plugin-registered CompilationParserFactory that each pipe reader mints its own parser from). See slice-plans/compilation-mode.md.


              :describe-active-modes — buffer mode stack (✅ 2026-08-04)

              <C-h>m had been bound since K.3.2 but routed to :describe-mode, whose arg is required — so it prompted for a mode name instead of showing the buffer's active modes, which is what its own doc table, its HelpPrefixEntry doc, keymap-architecture §12.1, and docs/user/help.md all claimed. Four documents describing behaviour that never shipped.

              DAM.1–DAM.5 made the documented behaviour real: an additive :describe-active-modes + Effect::DescribeActiveModes (additive rather than widening describe-mode(string) in wit/types.wit, which would break a published plugin API), rendering major + minors with each mode's contributed chords read from DynMode::keymap(). <C-h>M keeps the prompt-for-any-mode path. DAM.6 repointed <C-h>K to a new :describe-bindings scoped to the current buffer, leaving :keymap as the exhaustive reference.

              Turned up mid-build: : line candidate matching is fuzzy subsequence, so describe-mode also matches describe-active-modes — which broke :describe-mode<Tab> no matter what the new command was named. Fixed generally: on the command-name slot a literal prefix beats a fuzzy subsequence (arg/file slots keep full fuzzy matching). Also fixed in passing: build_describe_mode_content computed "active on current buffer" from document_buffer_id, wrong for every non-document buffer.

              Design: ../architecture/keymap-architecture.md §12.5–§12.6. Slice plan: slice-plans/archive/describe-active-modes.md.


              Pane zoom (✅ 2026-09-09, ZP.1–ZP.5)

              tmux-style non-destructive zoom of the active pane within a tab. <C-w>z / <C-w><C-z> / :zoom-pane give the active pane the whole tab and the second toggle restores the split verbatim — the difference from <C-w>o / :only, which really closes the other panes.

              State is PaneTree.zoomed: Option<PaneId> rather than a field on Editor, because TabSlot stashes a whole tree and tab switching is a mem::swap: a flag on the tree is per-tab for free, where an Editor field would be a second thing every future tab operation has to remember to stash. A PaneId and not a leaf index, since close_active renumbers every index above the removed one.

              The whole TUI feature is one branch at the head of compute_rects, which is the single canonical layout function — the draw path, per-pane viewport sizing (which resizes terminal PTYs), mouse hit-testing and the pane-height motions all route through it. It also makes zoom cheaper than not zooming: hidden panes get no rect, so no element fan-out happens for them (ZP.5: flat ~20 ns vs. 65 ns at 8 panes).

              Two things are load-bearing and were not obvious:

              1. Zoomed ⟹ active, enforced inside PaneTree on set_active / split_active / close_active / collapse_to_active, not at the call sites. That invariant is why the ~6 existing rects.iter().find(active_idx) lookups keep working untouched — under zoom the list has one entry and it is theirs. Without it they fall to their unwrap_or defaults and the active pane's height silently becomes the whole screen.
              2. navigate reads an explicitly-unzoomed layout (compute_rects_layout). On the zoom-aware view it would see a one-entry list, find no neighbour in any direction, and <C-w>j while zoomed would silently do nothing. Reading the real layout makes the navigation keys the escape hatch out of zoom, matching tmux's select-pane and Zed.

              Resize and equalize are refused while zoomed rather than applied invisibly — unzooming would otherwise hand back a layout reshaped by a key pressed against a full-screen pane.

              The GPUI peer does not call compute_rects; it recurses over PaneNode. PaneTree::render_root() hands both of its walks the node to paint, expressing zoom as "for painting, the tree is one leaf" so the recursions stay unchanged. collect_pane_geometries needed it as much as the paint walk: that is the loop firing set_pane_viewport, so zoom honoured only in paint would leave a zoomed terminal still wrapped to its unzoomed width.

              A Z marks the zoomed pane on its modeline (core.zoom) and its tab, gated by one pane.zoom-indicator option (both | modeline | tabline | none) rather than a boolean per surface. The tabline marker is not redundant with the modeline one: zoom is per-tab, so it is the only surface that can report a background tab.

              Design: ../architecture/pane-zoom.md. Slice plan: slice-plans/archive/pane-zoom.md (ZP.1–ZP.5).


              Pane buffer history (✅ 2026-08-04, PBH.1–PBH.6)

              Per-pane back/forward over the buffers a pane has shown: <C-6> back, <C-7> forward, :history pane-buffers picker, and a typed pane.buffer-history-size bound (default 100). Splitting a pane starts a fresh trail rather than cloning one.

              Storage is a HashMap<PaneId, PaneBufferHistory> side table on Editor, deliberately not a field on PaneState: that type is Copy and split_active copies it field-wise, so a history field there would be inherited by the split — the one behaviour the feature must not have. Keyed by a process-monotonic PaneId, a fresh pane simply has no entry, so the requirement holds by construction. GC reconciles against PaneTree::leaves rather than hooking removal sites.

              Rejected: filtering the existing position-history ring per §5.1.1. PositionEntry carries no pane_id, and the ring records every AutoJump — so scrolling inside one file would evict the record of which buffers a pane visited.

              Recording has three call sites, not one. The first design claimed activate_buffer_only was the single funnel for "this pane now shows a different buffer". It is not, and each correction was found by a test rather than by reading:

              1. activate_document — reached directly by do_edit's already-open branch and by the <C-o> walk.
              2. open_fresh_into_active_slot — swaps document_buffer_id by hand, and is the commonest switch of all (opening a not-yet-open file). It recorded nothing until PBH.6's picker test exposed it. :e! reloads repoint the current entry instead of pushing, since the pane still shows the same file with a fresh actor.
              3. activate_buffer_only — still needed for non-document kinds (help, oil, file tree), which never reach the other two.

              Overlap between them is a no-op by construction: push ignores a visit to the buffer already current.

              Previews cannot pollute a trail — they route through set_preview_override and never touch the pane's committed buffer_id. :bd purges eagerly across every pane; the walk also prunes lazily for the other buffer-dropping paths.

              Chords are <C-6> / <C-7> because terminals send 0x1E / 0x1F and crossterm maps that range to Char('4'..'7') + CONTROL; a <C-^> binding would never fire. Landing them exposed a pre-existing bug where the count-prefix path swallowed every <C-digit> chord in Normal mode.

              Design: ../architecture/pane-buffer-history.md. Slice plan: slice-plans/archive/pane-buffer-history.md (PBH.1–PBH.6).


              plugin-contributed languages (LG-series, ✅ LG.0–LG.6, 2026-08-22 → 2026-08-23)

              "Which languages exist" stopped being a compile-time property of the editor. A plugin ships a tree-sitter grammar compiled to WebAssembly plus its queries; the language lands in the SAME registry the bundled ones live in and is selected, highlighted, folded and reparsed through the ordinary paths, with no host kind-branch anywhere.

              SliceWhat
              LG.0 ✅Gate: wasmtime-c-api 36 and wasmtime 46 coexist, proven under a forced guest trap in both init orders
              LG.1 ✅Gate: wasm vs native parse — 2.0× cold, 1.25× incremental
              LG.2 ✅Lang::Plugin(LanguageName) + the runtime language registry
              LG.3a ✅The live LangRegistry (RCU, teardown by provenance)
              LG.3b ✅Loading grammars from wasm; the stores parsers need
              LG.3c ✅The language WIT seam, loader drain, teardown
              LG.4 ✅Org: per-level headlines via #eq? predicates
              LG.5 ✅Org: folds over sections, blocks and drawers
              LG.6 ✅Docs, ledger; org's :help page ships

              Both gates passed, so the design's §6 fallback (bundle org natively) was never taken. Two decisions worth finding here rather than in a diff:

              • tree-sitter/wasm is linked unconditionally, not behind a cargo feature: +5.7 MiB measured, zero runtime cost when unused. Gating it would have meant a stock build where a user's language plugin silently does nothing. Design §3.2.
              • Org is NOT a bundled plugin. Its parser.c is 2.2 MB of generated C and plugins/ is compiled by every workspace build. lattice-org-plugin holds version-controlled reference source that nothing in the workspace builds; the plugin manager clones and builds it on boot (PM.5–PM.8). Design §7.

              Phase 8b gains a second reference plugin (lattice-org-plugin) alongside the bundled auto-pair and treesitter-context.

              External plugins can compose worlds, so org ships its :help page. A component implements exactly one WIT world, and combined worlds live in lattice's wit/ — which an external plugin cannot add to. But WIT include composes worlds and wit-bindgen resolves an inline package against the interfaces at path, so a plugin declares its world locally and gets one Guest trait with both exports, with no change to lattice's WIT. The language-guest fixture composes its world the same way, so the route external plugins must use is the one under CI.

              Bug found while proving that, and fixed: a multi-seam plugin reversed only its FIRST seam. Each spawn_* calls alloc_id, so N seams means N host ids; the loader kept only the first and teardown reversed by it. Token-based reversals were fine; provenance-keyed ones (help topics, dashboard sections, languages) were not, so bundled auto-pair (grammar, modes, config, help) leaked its :help pages on unload.

              The loader now carries every seam id (PluginTeardown::seam_ids) and each provenance-keyed reversal iterates them. Note what was NOT done: memoising one id per plugin on the manifest id would be simpler and is unsafe — the manifest id is guest-controlled, and a host-issued provenance must never derive from guest input, or a plugin could declare id = "auto-pair" and inherit its provenance. The existing provenance_ids_are_host_issued_unique_and_stamp_the_plugin_layer caught that attempt.

              Design: ../architecture/plugin-languages.md. Slice plan: slice-plans/archive/plugin-languages.md.


              Org-mode as a plugin (2026-08-24 → 2026-08-25)

              The second reference plugin, and the one the extensibility goal was built to survive. It lives outside this tree (lattice-org-plugin) because that is what a user's plugin manager clones and builds.

              GroupSlicesStatus
              PrerequisitesOM.0 drain-order gate, OM.1 language index, OM.2 majors over the seam, OM.2b <leader>
              OutlinerOM.3 promote/demote, OM.4 motions, OM.4b operator-pending, OM.5 <Tab>, OM.6 subtree move, OM.7 TODO, OM.8 checkboxes, OM.9 timestamps, OM.10 links
              TablesOM.12 align + cell motion, OM.13 rows and columns
              AgendaOM.A1 the agenda-source seam, OM.A2 org semantics, OM.A3 the view's modes
              DocsOM.14
              Cross-file writesOM.6b.0 document.path(), OM.6b archive, OM.11.0 picker→action, OM.11 refile + capture

              The acid test, asserted rather than claimed. Zero Editor:: method additions, zero new variants in the host's Action enum, no BufferKind::Org, no Lang::Org, no org branch in either renderer. Org contributes a language, four modes, a grammar, options, media blocks, agenda rows and its own :help page, and the host names none of them.

              Four host changes across the whole epic, every one of them generic.

              • A language index on ModeRegistry (OM.1). major_mode_id_for_lang is a hand-written match whose Lang::Plugin(_) arm returns None by design, so a plugin language had no way to have a major. The index is the third layer, consulted after the built-in table — a plugin claiming "rust" cannot take rust-mode's language.
              • mode-declaration.target-language (OM.2), plus lifting the reject arm that had kept major off the modes seam. Two silent bugs came with it: bind_mode_keymap hard-coded MinorMode(id), so a major's chords would have resolved at the wrong priority; and teardown removed only MinorMode(id), leaking an unloaded major's keymap layer.
              • <leader> expansion (OM.2b), carved mid-build because it did not exist. Every org chord is <leader>o… and every one of them would have been skipped with a warning: the plugin loads, the mode activates, the keys do nothing.
              • The agenda-source seam (OM.A1), the only WIT interface the epic added — deliberately last, so the ABI addition was made once, informed by what org turned out to need.

              Three findings worth carrying past org.

              • Seam drain order was NOT guaranteed (OM.0). The loader walked manifest.provides verbatim, so a load-bearing invariant depended on a hand-written comment inside a guest-authored TOML file. Fixed structurally: PluginSeam::drain_rank() ranks by real registration dependency and the loader stable-sorts. What the regression test asserts on the way past is why it was invisible — the mode registered in every order, and only its bindings silently vanished.
              • Effect::None is not Effect::Declined (OM.6). A declined chord is re-resolved with the mode's layer removed, and for a MULTI-KEY sequence that runs the trailing key alone: <leader>oJ executed vim's J and joined two lines. Decline only a chord that is genuinely SHARED; a chord behind a plugin-owned prefix has nothing underneath it and must consume the key.
              • A plugin minor is INERT until enabled (OM.7). The manifest's default_mode is what publishes the enablement request; without it the mode registers correctly and never activates. Two per-tick drains then have to run in order.

              The agenda is a multibuffer, literally (OM.A1–A3). An agenda row is an Excerpt, which buys jump-to-source, edit-propagates-to-source, headerline status and refresh from machinery that already ships — and the second of those is what decided it, because org's agenda is a place you change TODO states from. Host-side this added providers::agenda (the walk, the cross-file stable sort, the excerpt build), agenda-view-mode (gr), the AgendaSourceRegistry seam and an :agenda ex-command; none of it knows what an org file is, because the guest declares which extensions it wants offered. The command reaches the view through PV.1's generic provider-view seam, so it needed no dispatch arm at all.

              AG.1 later moved the ex-command out of the host — see below.

              Two pre-existing bugs surfaced by the agenda work, both silent: WiredSeams never reported media_registry, and the loader never stored its media teardown snapshot back into the ArcSwap — so unloading a media plugin left its producer registered and every image still requested.

              What is blocked, and why it is one decision rather than two. <leader>o$ (archive) and :org-refile / capture all move a subtree to a file other than the buffer's own, and no effect in the WIT surface writes to another file. apply-edit takes a target buffer id the guest cannot learn for a file it has not opened, and invoke-command exists only as a picker-accept outcome, not as an effect, so there is no way to chain "open that file" into "now edit it".

              Decided 2026-08-25 and shipped 2026-08-26 (XF.0–XF.6): a new host-mediated effect, write-to-file, gated on the fs:write capability that already existed — ../architecture/cross-file-writes.md, slice plan slice-plans/archive/cross-file-writes.md. Archiving in place was rejected (it renames the command users came for, and does nothing for refile or capture); so was leaving the move to the user. The effect writes through the document pipeline rather than to disk, so an already-open target sees the edit, u covers it and the LSP hears about it — and the capability check runs at the boundary, where the plugin's provenance is still known, rather than at the dispatcher, which deliberately cannot tell a plugin's effect from a native mode's. That enforcement shape is the one AppEffect::OpenProviderView's withheld WIT surface will reuse.

              Shipped as XF.0–XF.6. Effect::WriteToFile { path, anchor, text, cut } plus FileAnchor; Editor::resolve_path_to_buffer (open-or-reuse, in the background, listed, never stealing focus); an applier that inserts first and cuts only on success; and EffectAuthorizer, built once per plugin at load from its grant. org-mode.md's OM.6b and OM.11 are unblocked and are now ordinary plugin work.

              And the effect alone was not enough for archive. org-archive-location names <file>_archive, derived from the source, and no grammar seam let a guest learn which file it was editing — action-context carries a cursor and a buffer id, the document resource read text but not identity. OM.6b.0 added document.path() to that resource (not to a context, where the same field would have to be re-added to three more and every dispatch would pay for it), threaded the path through all three grammar contexts, and retyped Document::path as Option<Arc<PathBuf>> so carrying it is a refcount rather than an allocation on the keystroke path. The host's action gate then had to put the path on the throwaway document it hands a guest — Editor::active_document_path, the peer of active_text — which no host-side test caught, because they all build a context directly and never go through the gate.

              And refile needed a third. Its shape is "pick a target, THEN read the subtree at the cursor", and a picker's InvokeCommand outcome rendered the choice into a : line — which cannot reach an action at all (excommand::parse answers Unknown for CommandKind::Action) and reaches an ex-command that gets no document handle. OM.11.0 dispatches an action with the typed args the source already holds, keeping the ex-line path for everything else; a value containing a space stops arriving as two arguments on the way. The fourth repeat of the TC.6 multi-seam linker rule came with it: org provides grammar AND picker-source, so host-services had to join the sync grammar linker or the whole component failed to load.

              Four bugs the slices caught, each silent and each now a test: Path equality does not collapse .. (so a producer path containing one would have opened a SECOND buffer over an already-open file and lost the user's unsaved work); content_line_count strips the phantom trailing line, so the obvious append position does not exist on an empty file and archiving into a fresh one did nothing; appending to a file whose last line lacks a newline spliced onto it; and re-entering BufferRegistry::for_each deadlocks rather than fails. The gate's wiring is verified by mutation — unwiring authorize fails exactly the three denial tests.

              Design: ../architecture/org-mode.md. Slice plan: slice-plans/archive/org-mode.md.

              Org on tree-sitter, then org clocking (✅ complete, 2026-08-29): the plugin registered the org grammar and then parsed org with hand-rolled string scanning everywhere — TreeSnapshot reached apply-action and was dropped unused. So org migrates to the tree first (OT.x), and clocking — deferred by org-mode.md §10 and the reason org-capture.md §3 cut :clock-in / :clock-resume — lands on the migrated base (OC.x), including ML.6 (the plugin modeline API, which is why slice-plans/archive/modeline.md could finally be archived).

              Both phases are complete ✅ — OT.1–OT.8 and OC.1–OC.11. Phase 2 grew three slices it did not start with, and the reason is one thing said three ways: clocking kept walking into seams that were wired end to end and answered nothing. A grammar action could call emit-event and be silently dropped (OC.1). A plugin could not be woken at all, so anything periodic waited for a keystroke (OC.2). An ex-command was handed no cursor and no buffer id while still being offered an apply-edit effect it had no way to construct (OC.10) — which is also why :org-clock-in could not exist until it was fixed. None was reachable by a test that builds its own context, which is what all three have in common. Clocking itself also turned out to need no persistent state: an unterminated CLOCK: line IS a running clock, so the buffer is the record. An earlier revision of this paragraph named a specific live bug (agenda.rs:111, the planning line's position) and was wrong — org's grammar puts plan before property_drawer, so the old line assumption matches org's real rule; OT.3 retracted it after failing to reproduce it three times. The genuine divergence is context a line cannot carry: a * TODO line inside a #+BEGIN_SRC block is example text to the grammar and a headline to ^\*+ , so the text path invents agenda rows and resolves dar over spans that are not subtrees.

              The phase also closed three links that made the seam inert or wrong, none of them visible from a unit test: DispatchEnv carried no tree snapshot, so OT.1's motions and text objects received none on every real keystroke (OT.4); org's manifest never declared the tree-sitter capability, so the trampoline would have withheld the handle anyway (OT.4); and both plugin gates handed over trees from before the previous keystroke's edit, which is worse than handing over none — they now gate on tree_reflects (OT.7). Every existing seam test passed against all three, because they build the GrammarEnv they are testing. See ../architecture/plugin-treesitter-seam.md §5.1. Slice plan: slice-plans/archive/org-treesitter-and-clocking.md.


              The agenda's files, and its trigger (AF / AG, 2026-08-28)

              SliceWhatStatus
              AF.1agenda-source.roots() — a source names what to walk
              AF.2AgendaOptions.roots, files-or-directories, precedence
              AF.3org.agenda-files, and the config registry the agenda store lacked
              AF.4docs
              AG.1open-provider-view crosses; org owns :org-agenda

              The agenda scanned ONE root, derived from the working directory unless :agenda <path> said otherwise — so it answered a different question depending on which checkout you were in. Org's agenda is not that; it is the set of files you keep your life in, the same set from anywhere. There was no option at all.

              The list belongs to the source, not the host. The recommendation was the opposite — the walk, the file offering and the multibuffer are all host-side — and it was decided the other way on the argument that outweighs it: every source's users already have a name for this list in whatever ecosystem the source came from, and a host-side setting would have to pick one of those names or invent a neutral one nobody's fingers know. So the host asks each source and merges, and never learns any option's name.

              AG.1 moved the trigger too. :agenda lived in lattice-multibuffer because a plugin could not open a provider view — OpenProviderView was withheld from the WIT app-effect mirror as a capability question. The precedent had already answered it: Effect::OpenPicker and Effect::OpenTransient both cross ungated and open any registered source by name. Withholding it never withheld the capability; it only stopped the one seam that needed it from naming its own trigger, so a feature every user calls org-agenda shipped as :agenda. :agenda is gone rather than aliased.

              A third dead gate, found the same way as OT.4's two. config.get-option answered none inside every agenda call: the agenda store was the only seam store never handed the option registry (context / event / transient / grammar all get it; 73842466 fixed the same omission for events). Org's todo-keywords was already being ignored in begin. It survived because every agenda test drove extensions / begin / scan, none of which reads an option — the config path had never been called once. The WIT world had the matching hole: agenda-source-plugin did not import config, so it declared an export whose whole purpose is to answer from configuration while giving a pure agenda-source plugin no way to read any.

              Slice plan: slice-plans/archive/org-agenda-files.md.


              Plugin-contributed transient menus (2026-08-26)

              SliceWhatStatus
              TR.1TransientSourceRegistry service registered by editor_boot
              TR.2aTransientBuild::{Ready, Future} + per-row Action args
              TR.2bthe transient-source WIT seam + loader drain + teardown

              A transient is a keyed menu — one keystroke per row, fires and closes. The mechanism is lattice-picker's and magit was its only user; a plugin could open magit's menus and none of its own, so org's capture menu (one row per template) was inexpressible.

              The registry existed only if magit was installed. lattice-magit::install constructed it and registered the service, so a plugin menu would have worked or not depending on whether an unrelated feature crate happened to load — a dependency nothing declared and nothing would have named at the point of failure. TR.1 moved the pair to editor_boot.

              Two things had to change before a guest could answer at all. The registry stored a synchronous builder, which is right for a native menu and impossible for a guest one (build is an async call on the plugin's own actor task). Builders now return TransientBuild::{Ready, Future}, the host parks a future in Editor::pending_transient_build, and it seats on the async-landed wake rather than the next keystroke. And TransientItemKind::Action gained an args slot: the menu's TransientState projection is per-MENU, so a menu whose rows differ only in a parameter had no expression — which is the shape every plugin menu has.

              Both renderer peers' Effect::OpenTransient bodies collapsed onto Editor::open_named_transient on the way, so the async path exists once.

              Design: ../architecture/plugin-transients.md. Slice plan: slice-plans/archive/org-capture.md, which sequences it with the org capture overhaul that motivated it.

              OC.9 / OC.10 (2026-09-08) reopened this plan. Effect::WriteToFile grew save, and capture is the one org producer that asks for it — until then a captured entry lived only in the target BUFFER, so the user was asked about an unsaved file they never opened and the agenda (which scans from DISK) could not see the capture at all. The <C-c><C-c> / <C-c><C-k> pair also gained Insert peers, which surfaced a host bug fixed alongside: Editor::modal is one field, so deleting the capture buffer from Insert left the successor receiving keystrokes as text. OC.11 is open (⛔) — a REJECTED option is indistinguishable from an unset one inside a guest, so a malformed org.capture-templates silently falls back to the legacy pair. See ../architecture/cross-file-writes.md §7.1, which records the reversal of that section's own "Rejected: a save: bool" paragraph rather than deleting it.

              Org capture is complete (OC.1–OC.6). Many keyed templates, %^{Question} fields, file and file+headline targets, and the placeholder set (%? %U %T %t %a %%). Two host slices fell out of it: default_modes (plural) so a plugin may have more than one mode on by default, and host-services.read-file — a grammar action cannot read a file through WASI, because the grammar seam's synchronous linker blocks on a runtime that the dispatch thread is already inside, so the read has to be host-side. That constraint is now in the plugin-authoring guide; it is invisible until it panics.

              The template SHAPE was reworked (CT.1–CT.8, CT.10 ✅; CT.9, the user's own init.rs, 🚧). Template and RoamTemplate are two hand-maintained declarations of one thing — emacs holds org-capture-templates and org-roam-capture-templates as separate variables of the same type, and lattice's own pipeline already converged on one CaptureDestination. Decomposing the declaration into three orthogonal axes (type / target / body source) is what lets table-line, file+datetree and a generalised body-file each land once instead of twice. No host slice: it is guest-side throughout. Design: ../architecture/org-capture-templates.md; slice plan: slice-plans/org-capture-templates.md.

              Captures are draft files (CD.1–CD.8 ✅; CD.9 ⛔ deferred). Any number at once, saved with :w, resumed from <leader>oC, filed back to where they were started. A roam note created inside a capture links into it (C-c n i) or back to it (${origin}). The target is checked at open, and a write that does not land stops the rest of its action, so a failed commit keeps the draft. Host seams added: effect.focus-buffer, open-buffer-at content / activate-minor, host-services.delete-file / can-write-file / clamp-position, effect.invoke-command, and open-picker query. Design: ../architecture/org-capture-drafts.md; slice plan: slice-plans/org-capture-drafts.md.


              Concealment (H, 2026-08-29 — ✅ complete)

              SliceWhatStatus
              H.1the shared coordinate fn, the conceals range list, the clamp
              H.2conceal-rule on the language seam; compile + validate at registration
              H.3the matrix build elides; the conceal axis; the bench
              H.4mode scoping — Insert reveals, gated on the language having rules

              Design: ../architecture/conceal.md. Slice plan: slice-plans/archive/conceal-and-org-links.md.

              design.md:3414 named concealment as a v1 carve-out and recorded that nothing implements it; nothing has since. Help buffers look like a counter-example and are not — they strip [label](url) down to label at build time and keep byte ranges as metadata (lattice-help/src/lib.rs:196), which is legitimate for a synthetic read-only buffer and unavailable for a file the user edits. The mechanism does not generalise, so the first editable buffer wanting rendered markup needs a display-time one. Org links are that buffer.

              Conceal bakes into DisplayLine.text rather than into either renderer, so TUI/GPUI parity is structural: text + runs are what both peers already consume, and a renderer that does nothing new is already correct. col_map goes signed — inlays insert columns and conceal removes them, one axis with opposite sign — beside a conceals range list that clamps a byte falling inside a hidden span to that span's start column.

              Rules are patterns, not tree-sitter captures, and the reason is worth recording because the tree looks like the obvious source. tree-sitter-org has no link rule at all: [[id:X][Title]] is undifferentiated expr tokens inside item / paragraph, so there is nothing to capture. And a tree is absent mid-reparse, so tree-driven conceal would flicker between concealed and raw while typing — a pixel change to unedited content, which is a standing veto. links.rs already recorded the same reasoning for its own text scanning. The language seam gains conceal-rule { pattern, hide }; the host learns to evaluate rules and never learns what an org link is.

              Insert reveals buffer-wide, chosen over vim's line-scoped concealcursor with the cost stated rather than buried: i repaints every visible line carrying a concealed range. It is a caused change at a mode boundary that is already a visual event, :set list already does the same class of thing through the same kind of axis, and the bump is gated on the buffer's language having rules so a Rust buffer never enters the path.


              SliceWhatStatus
              OL.1Target::Id — a recognised kind that fails honestly
              OL.2org declares its two conceal rules
              OL.3<CR> follows, and declines when there is nothing to follow
              OL.4docs — design §7, the org help page, the ledger, the site

              Design: ../architecture/org-mode.md §7. Slice plan: slice-plans/archive/conceal-and-org-links.md.

              Org's links already work — links.rs classifies file:, http(s):, mailto: and *Headline, and <leader>oo opens them. What is wrong is the surface. A link renders as its full source text, so a roam-style link spends 70 columns saying four words; and following one takes a three-keystroke leader chord where every other editor, and lattice's own help buffers, use <CR>.

              Rendering is phase H's; org contributes two conceal-rules and nothing else. An earlier revision of this row claimed the described-link rule had to be ordered before the bare-link one, or a described link would be matched as bare and render as id:…][Project Kickoff Checklist. H.3 disproved that twice over and the retraction is recorded rather than overwritten: rule spans are unioned, so no rule consumes text before another sees it and order cannot change the output; and independently the bare pattern's [^]]+ stops at the first ], so it never matches a described link at all.

              <CR> binds to a second action, org-follow-link, and the split is forced rather than chosen: org-open-link (<leader>oo) must answer Effect::None on a miss while <CR> must answer Effect::Declined, and the vocabulary cannot say "it depends". Declining from <leader>oo would re-run its trailing o — "open a line below and enter Insert" — so a missed link would start editing the buffer. <CR> is a single key, so no trailing key and no hazard.

              A third claim died here. This row and org-mode.md §7.2 both said <CR> declines to the builtin first-non-blank-of-next-line motion. That is vim, not lattice: <CR> is unbound in Normal for a Document buffer (keymap_normal.rs has it only as the z<CR> suffix; input.rs routes a bare <CR> to FollowLink only for Help / Dashboard / Oil / FileTree). Lattice has no + / <CR> motion at all — a real vim-grammar gap, noted here and left as separate work. The decline is therefore a no-op today and stays Declined for meaning and future composition, which is the limitation OM.5 already records for <Tab>.

              Target gains an Id arm that org cannot resolve — that is ../architecture/org-roam.md's index. It is a slice rather than a line of OR because the status quo is actively misleading: with no id: arm, classify falls through to the file branch and [[id:6F398E54-…]] reports "no such file: id:6F398E54-…", blaming the filesystem for a missing index and sending the user after a file that was never meant to exist.


              Org-roam (OR, 2026-08-29 — 🚧 in progress)

              SliceWhatStatus
              OR.1host-services store-* — durable, plugin-scoped, opaque bytes
              OR.2host-services.watch / unwatch — a debounced directory watch
              OR.3host-services.new-uuid
              OR.4org.roam-directory and the indexer
              OR.5the picker offers to create what it could not find
              OR.5bone component may register N picker sources
              OR.6:org-roam-find-node
              OR.7inserting a link — a completion source, not a picker
              OR.7c:org-roam-insert-node as a picker
              OR.8id: resolves — <CR> jumps, :org-roam-id-create mints
              OR.9backlinks — a picker, and why not a multibuffer
              OR.10dailies — the journal, and two host defects it surfaced
              OR.11a${field} expansion — written and wired
              OR.11bthe capture buffer — roam's draft, %^{…} and C-c C-k
              OR.12docs — doc/org-roam.md, its own :help org.roam topic

              | OR.7c | :org-roam-insert-node as a picker — C-c n i | ✅ |

              Design: ../architecture/org-roam.md. Slice plan: slice-plans/archive/org-roam.md.

              OR.7c landed 2026-09-08, closing the plan. It was deferred with a condition attached — "revisit only if completion proves insufficient" — and completion did prove insufficient: it offers all 585 corpus nodes the instant you type [[, ranked against an empty query. Both surfaces stay; the picker reuses roam_find's candidate set and differs only in routing.

              OR.10 landed two fixes in the editor before the plugin slice, each its own commit. EffectAuthorizer::resolve_for_compare canonicalized only the immediate parent of a not-yet-existing write target, so a write into a directory that was also new fell back to the raw path and was compared against a canonicalized prefix — which on macOS never matches, because /tmp and /var/folders are symlinks into /private. And Effect::WriteToFile gained create_parents, the producer opting out of the missing-parent refusal for a directory that is part of the layout it owns; see ../architecture/cross-file-writes.md §8.1 for why that is opt-in and why capture's target is not.

              Plugin-owned multibuffer views (MV)

              SliceWhatStatus
              MV.0the seam stops claiming to be the agenda (scanned-excerpt-source)
              MV.1athe seam and its bridge
              MV.1bthe generic provider, its drain, and two security fixes
              MV.2org registers a view⛔ dropped — folded into MV.3
              MV.3the agenda migrates onto it
              MV.4docs

              Design: ../architecture/plugin-multibuffer-views.md. Slice plan: slice-plans/archive/plugin-multibuffer-views.md.

              Came out of OR.9. A plugin can feed rows to the one multibuffer the host built for it, and can own that view's chords through the view-mode export — but it cannot create a view, name it, order it, or refresh it, because ProviderViewOpener is a native Rust closure with no WIT path. Org's second view had nowhere to go, and neither would any third-party plugin's first.

              org-mode.md §10 lists org-roam as a cut, and OT.3b's note on why the agenda's cache could not serve it — "org-roam, when it comes, does not want a snapshot cache; it wants a real index" — is the sentence this plan starts from.

              The constraint that reshapes the obvious design is that there is no single org guest instance. spawn_event_plugin, spawn_config_plugin, spawn_help_plugin, spawn_dashboard_sections and instantiate_grammar_plugin are separate paths, each with its own wasmtime::Store, so the picker seam running find-node and the sync grammar seam running <CR> have different memory. "Keep the index in guest state" therefore means N copies, drifting — and the drift is invisible, because each instance stays internally consistent while find-node offers a node <CR> cannot open. Hence one indexer, many readers: the async event seam is the sole writer, which is also what makes denormalizing the records safe.

              Three generic host-services additions, none of which names org. A durable plugin-scoped byte store, scoped by manifest id so every seam instance of one plugin sees one store — this promotes agenda_cache.rs's crash-safety policy (temp-and-rename, schema version, degrade to empty never to a partial read) from an agenda special case to a primitive. A debounced directory watch, chosen over a save hook because the corpus is edited from outside lattice and a picker missing notes you know you wrote reads as data loss — addressed rather than broadcast (Event::FilesChanged carries the host-issued plugin id and the delivery actor drops what is not its own), because the bus is a broadcast and a watch is a capability. And new-uuid, which exists for read-file's exact reason: :org-roam-id-create is a grammar action, and the grammar seam's synchronous linker cannot serve WASI, so a guest-minted id would work on the picker path and take the plugin down on the grammar path.

              The corpus was measured rather than assumed (706 files, 585 nodes, 795 id: links, 71 aliases). Two numbers changed the design: 110 of the nodes are headline-level, so a file-only model would silently drop 19% of the corpus and every link into it; and a filename is a fossil — 20250603103551-chicken_breast_honey_garlic.org holds a node titled Honey Garlic Chicken Breast — so the picker matches titles and aliases and never filenames.

              Create-on-no-match is generic picker surface (OR.5), not roam surface. The synthetic row is present whenever the query is non-empty rather than only on zero matches, because otherwise you cannot create Rust while Rust Async exists; and it is pinned last and never ranked, because a create row that could sort above a real match would let <CR> produce a duplicate through ranking noise.


              Where org is documented, and why it is here

              docs/dev/architecture/org-{mode,agenda,capture,habits,roam,todo-keywords}.md describe a plugin that lives in another repository (dhruvasagar/lattice-org-plugin). Moving them there was considered and declined: they double as the worked example for the plugin seams. The agenda drove the multibuffer scan-view seam, capture drove cross-file writes, habits drove per-row annotations — a contributor reading "how do I build a plugin that contributes a view" wants those pages, and they are useless three repositories away from the seams they exercise.

              So they stay, and carry their weight by being explicit about what they are:

              • Every one opens with a "Where the code is" callout naming the plugin repository and stating that nothing on the page is compiled into the editor. org-agenda.md and org-habits.md were missing it and now have it.
              • Where a page documents something that IS lattice's, it says so. The two cases are org-agenda.md §5 (display-span colour) and §5b (the annotation row): both are contracts with any scanned-excerpt-source provider, and org is their first consumer rather than their owner.
              • Each links to the user page and to the plugin's own doc/org.md.

              User docs. docs/user/org.md is the org page a user reaches through :help org and through the site's Configuration & extension section. It is deliberately thin — install, what you get, the org.* options, and a pointer. The exhaustive reference stays in the plugin's doc/org.md and versions with the code; duplicating it here would put two copies in two repositories that release on different schedules, and the copy in the slower one would quietly become wrong.

              Site. Dev pages sit under the org section of dev-nav.toml; the user page under config in nav.toml. The sync resolves ../../user/org.md cross-links to @/docs/config/org.md, so the two halves reach each other.

              Org TODO keywords (TK, 2026-08-29 — 🚧 TK.8/TK.9 land 2026-09-03)

              SliceWhatStatus
              TK.1a capture name may name a theme element
              TK.2the org-todo-keywords grammar
              TK.3elements, and defaults that reference the palette
              TK.4the query is generated from the keywords
              TK.5org.todo-keyword-styles — the org-shaped override spelling
              TK.6fast select
              TK.7docs
              TK.8the (!) / (@/!) flags record a state change
              TK.9the note a (@) state asks for

              Design: ../architecture/org-todo-keywords.md. Slice plan: slice-plans/archive/org-todo-keywords.md.

              TK.8/TK.9 (2026-09-03). Reported from use: CANCELLED(c@/!) recorded nothing. The flags were parsed with full fidelity and Logging had zero consumers — a deliberate deferral the design stated, come due. Org's rule is a precedence rather than a union (new state's entry, else old state's exit); ! lands as one edit with the headline, @ changes the state and then prompts in org's order, so dismissing the prompt leaves the state changed and unnoted. Design §5 carries the detail.

              Reported from use: "TODO looks weird purple and all other states seem like a comment." Three causes compound. org.todo-keywords is one flat string, so a real emacs configuration cannot be written down — WAITING(w@/!) becomes a keyword whose name is WAITING(w@/!). queries/highlights.scm carries a hardcoded word list and admits it ("a static query cannot read an option"), so nine of thirteen keywords in a three-sequence config render as plain title text. And the four that match borrow the wrong meaning: TODOStyle::Keyword, which is whatever the theme paints if and return.

              Tree-sitter, which is the opposite of what conceal.md concluded, from the same premises. Org's grammar does model the keyword position ((headline (item . (expr)))), and highlighting changes colour rather than text — which the keystroke contract explicitly permits to be eventual ("syntax recolour may be eventual"). Conceal is on the wrong side of that sentence; highlighting is on the right one. A regex would also have to know the headline is not inside a #+BEGIN_SRC block, which is the phantom-match class OT.3 spent a phase eliminating.

              The one host change is generic: a tree-sitter capture name that is not a builtin category but is a registered theme element resolves to Style::Element. That variant exists for exactly this and says so — a plugin "can register a theme element by name but can never add a variant to a Rust enum" — and it was reachable from decorations and listing icons but not from a query. Every language gains the whole theme vocabulary; org is the first consumer, not the reason.

              The priority half is what would fail silently. capture_priority returns u32::MAX — lowest — for an unrecognised name, and org's query captures the whole (item) as @text.title.N. A naive element capture would lose that overlap and paint nothing with every part correct in isolation. Element captures therefore take keyword's priority, which also makes the change behaviour-preserving in every dimension except colour.

              org-todo-keyword-faces maps onto an element override in the theme scope, with org.todo.<KEYWORD> inheriting org.todo.active / .done so a keyword added later is styled sensibly rather than unstyled. That turned out to need one new mechanism after all: the theme seam had only register-element, which supplies a default — and a default sits BELOW the theme, so a plugin could not express "the user configured this and it must win". set-element-override is that missing step, auto-namespaced so a plugin can only restyle elements it owns.

              A generic ui.element.* config surface was recommended instead — it would serve every plugin and is the missing implementation of a mechanism theme-system.md §5 already describes — and Dhruva chose the org-shaped option. The cost is recorded rather than rediscovered: :colorscheme replaces the override map atomically, so per-keyword styles are dropped until the next reload, and that is documented on the option itself.

              Two silent-failure bugs were found by tests rather than by reading. config and theme both drain at rank 0 with a stable sort, so provides order decides — theme was listed first, which meant register_theme_elements could not read org.todo-keywords and registered elements for the default set only. And apply_renderer_effects in the plugin's test harness had no arm for Effect::ApplyEdit, which is deferred to the renderer rather than applied where it is produced; the edit was correct, present in the outcome, and dropped by the harness, which read as a product bug through three rounds of diagnosis.


              The agenda as a dashboard, and the capture buffer (2026-08-31)

              Continued in its own plan. The agenda's next phase — headline-only rows, agenda-aware colour, layered display modes, a tags/todo query language and org-agenda-custom-commands — is designed in org-agenda.md and sequenced in slice-plans/archive/org-agenda.md as OA.1OA.18. The table below is the phase that landed on 2026-08-31 and stays closed.

              SliceWhatStatus
              FW.1the cell matrix's chunk window is sized in buffer LINES, not screen rows
              FW.2highlight the visible RUNS, not the whole fold-stretched window
              AS.1agenda sections; undated TODOs become visible at all
              AS.2org.agenda-sections — your blocks, from TOML or init.rs
              AH.1grammars resolve from the LIVE registry, not a boot snapshot
              PS.1a plugin picker source can style its rows
              AF.1multibuffer fold grouping is declared by the provider
              AF.2the agenda opens collapsed to its blocks
              OC.7aa plugin can seed the synthetic buffer it opens
              OC.7borg-capture opens a buffer; C-c C-c / C-c C-k
              OC.7ca plugin major gives its pathless buffer a language
              OC.7dfinalize returns the pane to the origin buffer
              OL.1a conceal rule can style what it leaves visible
              CL.1reveal the cursor's LINE, not the whole buffer

              Four bugs here were invisible because every part looked right. Worth recording as a class, because reading the code did not find any of them:

              • FW.1viewport_height counts screen ROWS and was spent as buffer LINES. Identical until something is folded, and every test and bench in that area ran foldenable: false. A collapsed org file painted its first screenful of lines and left the rest uncoloured.
              • AH.1LangRegistry::standard() is registry::live(), and returns a SNAPSHOT. Two places captured one at boot, which is before any plugin registers a grammar, so a plugin language could never highlight through them. detect_from_path resolved .org correctly, the registry was wired, the grammar existed — only the registry being asked was wrong.
              • OC.7a — the modes seam is declaration-only, so a plugin mode has no on_activate and a guest could open a buffer it was then unable to write into. Capture fell back to a one-line prompt and the template was never shown.
              • OL.1 — org's highlights.scm had no link rule, and conceal made that worse rather than milder: it hides the brackets, so what survives is prose.

              A tried-and-reverted result worth keeping. The obvious fix for OL.1 was a highlights.scm link capture. The pinned tree-sitter-org has no link node (verified against its node-types.json); a query naming an absent node fails to COMPILE, and a failed query compile fails the whole language registration — so .org stopped resolving entirely. A newer upstream grammar does model links, and bumping to it is the alternative if conceal-based styling is ever too coarse.

              Two perf results. FW.2 took the collapsed-file build from 52.3 ms to 2.33 ms (22×) while leaving the fold-free path byte-identical; CL.1 kept per-line reveal off the cache-hit key so a cursor move stays 47 ns instead of becoming a ~1.5 ms window rebuild. Both are in benchmarks.md.


              C-c C-c and entry properties (OE) — 📝 planned

              Emacs' most-pressed org key, and the property writer it needs. Every verb org-ctrl-c-ctrl-c reaches already exists here (<C-c><C-q> tags, <C-Space> checkbox, <leader>t| align); what is missing is the dispatch — and org-set-property, which nothing covers at all.

              The design turns on one constraint: a guest cannot invoke a registered command. There is no Effect::Invoke, and the action names a guest may hand the host (confirm.yes-action, open-prompt.on-submit-action) all need a user interaction to reach. So an org-side dispatcher could not call action:table-align even though org buffers have it — and re-implementing alignment in org is what table-mode exists to prevent. The answer is the layer stack: each mode binds <C-c><C-c> and DECLINES when it does not apply, which is what Effect::Declined composes for now that it preserves the prefix and peels one layer per decline.

              One slice is a question rather than code. roam_index::id_drawer_insert puts a :PROPERTIES: drawer at headline_line + 1 unconditionally, while agenda.rs and clock.rs both say org's grammar puts plan first — so either :org-roam-id-create has been writing non-canonical drawers on scheduled headlines or the comment is wrong, and both callers inherit whichever answer the grammar gives.

              Design: ../architecture/org-mode.md §5.4–§5.5. Slice plan: slice-plans/archive/org-entry-editing.md.


              Org structure editing — headlines, lists, checkboxes (OS) — 📝 planned

              The outline half of org's structure editing has been here since OM.3 — promote, demote, move a subtree, insert a sibling. The list half does not exist at all: checkbox::parse_item requires a literal [ ], so a plain - milk is prose to every action org registers. No insert-item, no indent-item, no move-item, no bullet cycling, no ordered-list renumbering. org-meta-return is headline-only where emacs' M-RET has always dispatched on what is under the cursor.

              Two host gaps surfaced, and both are older and wider than org.

              • The TUI cannot express Shift+Enter. runtime.rs never pushes KeyboardEnhancementFlags, so <S-CR> / <C-CR> / <M-S-CR> arrive as a bare \r — unreachable for every consumer, though lattice-protocol has always been able to spell them and GPUI has always delivered them. The arrow half of emacs' vocabulary (<M-Left>, <M-S-Up>, …) needs nothing: modified arrows are ordinary CSI sequences.
              • A Visual-mode plugin action cannot see its region. lattice-mode's ActionContext has carried selection since MG.18e; lattice-grammar's — the one a plugin arrives through — never gained it, so the WIT mirror has nothing to copy. Verbatim the position OC.10 fixed for ex-command-context: a command reached that way was seeing strictly less than the same command reached by a chord.

              The binding mode is part of the design here for the first time, because <leader> cannot be typed in Insert and most of these verbs are wanted while composing. The split is what the verb needs from you: Insert gets creation and level-while-typing only; Normal gets everything that restructures what already exists; Visual gets the Normal verbs over a region.

              Explicitly not taken on: giving apply-operator a document. It is the better long-term shape — no plugin can contribute a text-transforming operator today — but no verb in this plan needs it, and it is recorded as a known gap in the design rather than put in front of eleven slices.

              Design: ../architecture/org-mode.md §5.6. Slice plan: slice-plans/archive/org-structure-editing.md.


              Which-key — pending-chord discoverability (WK, 2026-09-09) ✅

              Hold a chord prefix; after which-key.delay (300 ms) a panel along the bottom of the pane lists what can follow it. Eight slices, all landed.

              The load-bearing property is where the list comes from: the same composite trie the dispatcher walks, folded by KeymapHandle::continuations_with_context exactly as lookup_with_context folds it, differing only in the terminal step. :describe-bindings composes its answer from the static catalog plus each mode's declared contributions and can therefore advertise a chord that will not fire; this cannot, and the regression test activates a mode that shadows a builtin chord and asserts the builtin does not also appear.

              Two shapes worth knowing about elsewhere:

              • SubsystemBoot::idle_gate (WK.3) — a generic armed-deadline registry, the time-domain peer of inbound. Editor::inline_diag_deadline is the bespoke instance it exists to replace; that migration is deferred (it needs a CursorSettled event that does not exist), so the actor currently carries both arms.
              • PopupPlacement::MinibufferBand (WK.5 as PaneBottom; renamed and re-homed by WK.12) — full width, below every pane and above the : line, capped at half the body. Routed to its own slot, so it never evicts the popup. Available to plugins too: the WIT mirror gained the case rather than collapsing it at the boundary.

              The popup is PopupFocus::Passive, so no chord's meaning changes while it is up — the property that makes it safe under paramount #3. Design §7 records why a transient-style takeover was rejected.

              Design: ../architecture/which-key.md. Slice plan: slice-plans/archive/which-key.md, which also records three as-built deviations from the design.


              Conventions for updating this doc

              • Update the Phase status table whenever a phase advances.
              • Update the Vim grammar coverage table when a primitive lands; the status column uses ✅ done, 🔄 in progress, 🟡 partial, ⛔ pending, ⚠️ usable-with-caveats.
              • Update In-progress before each commit that lands the in-flight item.
              • Move completed items from Up next into the appropriate coverage table.
              • Update Test counts at the end of each session.
              • Don't write per-session log entries here — git log --oneline is the log.

              Feature checklist (moved from README, 2026-09-20)

              This checklist lived in README.md through 0.8; the README restructure for the 0.9 alpha launch (L.5) moved it here rather than deleting it. It is the granular pre-Phase-4 polish plan, plus Phase 4 work, checked off as it landed — kept for history, not maintained as a live tracker going forward (the Phase status table above and the per-feature sections elsewhere in this doc are the live ledger).

              The granular pre-Phase-4 polish plan, plus the upcoming Phase 4 work:

              Async runtime + cancellation (DESIGN.md §5.2, §5.6.8, §5.7)

              • DocumentActor + bounded-mailbox dispatch (one tokio task per doc)
              • arc-swap snapshot publish-before-reply contract
              • Pending<T> typed handles for every mutating call
              • Cooperative CancellationToken (grammar + actor + search loops)
              • Actor stress tests (mailbox saturation, concurrent senders, snapshot ordering)
              • Per-LatencyClass deadline timers (Reflex < 2 ms, Display < 10 ms)
              • Plugin async-task host primitive (Phase 7 — per-plugin Store as a tokio task)

              Vim modal editing (DESIGN.md §5.2)

              • Modal state machine: Normal / Insert / Visual / Op-pending / Command / Search / Replace
              • Strict vim grammar: counts, registers, operators, motions, text objects, ex-ranges
              • Built-in motion / operator / text-object catalog
              • Macros (recorded as CommandInvocation sequences, not keystrokes)
              • Marks, dot-repeat with insert-replay, position-history ring (§5.1.1)
              • Search + hlsearch with fancy-regex (RE2 + bounded NFA for backrefs)
              • Substitute (:s / :%s) with $1 / ${name} template syntax
              • Block-visual d / y / c / I / A / > / < (per-row dispatch + replicate-on-Esc + single undo unit)
              • Manual folds (zf / zo / zc / za / zR / zM / zd)
              • Counts on linewise ops (2dd, 2>>) collapse to one undo unit
              • Substitute live preview (matches highlighted while typing :s/pat/repl/...)
              • Computed folds — indent fallback (:set foldmethod=indent); tree-sitter folds queued

              Unified command / grammar dispatch (DESIGN.md §5.2.1)

              • One CommandRegistry for ex-commands, motions, operators, text objects
              • : line is a parser front-end producing typed CommandInvocations
              • Every command is reachable from : via the kind-prefix form (:motion goto-first-line, :operator delete word-forward, :text-object inner-word); ex-commands keep their bare alias surface; chord grammar stays the natural compact-typing path
              • :g/pat/body and :v/pat/body parse body up front (no per-match re-parse)
              • Range::Selection resolves to active visual selection
              • Interactive arg-prompts via args_schema (any required arg arms a prompt; Chord kind auto-submits on next chord)

              Event system + hooks (DESIGN.md §5.10)

              • Typed Event catalog in lattice-protocol
              • EventBus: subscribe(filter, target), unsubscribe, publish (kind-indexed dispatch)
              • SubscriptionTarget::Channel (mpsc) and SubscriptionTarget::Invocation
              • Actor publishes events on save / mode-change / quit (BeforeSave, DocumentSaved, ModalModeChanged, BeforeQuit)
              • Before*-event mutation / veto seam (the events fire; the mutate / abort return path is not wired)
              • :autocmd / add-hook front-ends — deliberate non-goal: handlers call typed APIs with an Event context, not :-command strings

              Self-documenting help (DESIGN.md §5.11)

              • Every command / option / mode / keybinding carries metadata at registration time
              • :describe-command, :describe-buffer, :describe-key, :keymap, :apropos
              • :describe-option, :options (typed options registry)
              • :help [topic] -- free-form topic surface with <Tab> completion; built-ins embedded via include_str! from docs/user/*.md; Dynamic body variant is the seam for LSP / plugin-supplied topics; :describe-* views emit "See also" topic cross-links
              • :describe-event / :describe-events, :describe-mode, :describe-active-modes (<C-h>m), :describe-bindings (<C-h>K)

              Configuration (DESIGN.md §5.12)

              • Renderer-agnostic typed-options crate (lattice-config): OptionType trait with primitive impls (bool, i64, String); Option<T> with ArcSwap<T> value cell for wait-free reads; OptionHandle<T> for zero-overhead typed access; ErasedOption + ConfigRegistry for by-name lookups; :set parser; gen:options completion source — every consumer (App, plugins, future renderers) registers options through the same API
              • register_core_options(&registry) → CoreOptions for the nine renderer-agnostic options (number, relativenumber, wrap, ignorecase, tabstop, foldenable, foldmethod, scrolloff, completion.auto_insert_single)
              • Renderer-specific options register via the same API: lattice-ui-tui::register_tui_options(&registry) → TuiOptions covers ui.dim_inactive, ui.separator, ui.separator_color, ui.statusline_active_fg, ui.statusline_inactive_fg. Future GUI / web renderers register their own
              • :set name=value, :set name, :set noname, :set name? parser front-end (drives ConfigRegistry::parse_and_set_command)
              • :describe-option <name> (reads the erased view: name, aliases, type label, default, current value, enumerated values, doc)
              • TOML static-settings layer — ~/.config/lattice/lattice.toml (lattice-config/src/loader.rs)
              • init.rs plugin loader (Rust → WASM) — landed with Phase 8 (lattice-plugin-loader)
              • :customize buffer view (group / mode / cross-mode) — [ ] write-back to TOML not wired
              • lattice config build diagnostic CLI subcommand
              • Project-local .lattice/config.toml (project-local init.rs still deferred behind a per-directory trust prompt)

              Rendering (DESIGN.md §5.6)

              • TUI renderer (crossterm + ratatui) — first-class peer for headless / SSH
              • Display-width-aware cursor placement (CJK / Latin / emoji)
              • Tree-sitter highlight emission (Rust / Python / JS / Markdown bundled)
              • Markdown grammar with fenced-code injections (```rust``` blocks highlight as rust)
              • Markup Style variants for headings (1-6), bold / italic, links, raw — themable from day one
              • GPU compositor via GPUI — full edit + LSP parity with TUI on both renderers
              • O(viewport) incremental highlight + cell build (flat to 100k-line files; H-series)
              • Soft-wrap on both renderers: reflow at pane width, wrap-continuation gutter marker, wrapped cursor movement
              • Event-driven TUI loop — 100ms poll replaced by reader-thread + Wake channel; idle CPU ≈ 0
              • Decoration retention: inactive panes keep full syntax + inlay hints + diagnostics; focus change = opacity flip only, zero recompute
              • Per-pane DisplayMatrix (keyed by PaneId): shared produce/consume path for both active and inactive panes
              • Renderer trait split (EditorRenderer / DocumentRenderer / TuiRenderer) — Phase 5/6
              • Sprite atlas for icons (file-type, severity, gutter, picker, status) — §5.6.7
              • Rich-buffer rendering (variable fonts within a single buffer) — Phase 9

              LSP (DESIGN.md §5.4) — Phase 4 (wind-down)

              • Diagnostics, completion (Insert-mode + LSP source + docs popup + snippets + ghost text), hover (K), go-to-definition family (gd/gD/gy/gI), references (gr), symbols
              • Signature help, rename + prepareRename, code actions + execute, formatting (range / on-type / format-on-save)
              • Inlay hints, semantic tokens, document highlights, folding ranges, selection ranges
              • Call hierarchy, type hierarchy, document links, code lens, document colors
              • window/showMessage, $/progress modeline, :lsp-restart, dynamic registerCapability / unregisterCapability
              • Cancellation tokens plumbed through every wrapper; per-server compatibility shims
              • 15 sub-modes toggle individually or via the lsp-mode umbrella
              • linkedEditingRange (needs shadow-edit machinery), inlineValue (needs DAP), inlineCompletion (lsp-types proposed) — deferred

              Plugin host (DESIGN.md §5.5, §9) — Phase 7 ✅ (runtime; editor-side loading is Phase 8)

              • wasmtime + Component Model + WIT bindings; per-plugin Store as a tokio task
              • Module cache; capability manifests + WASI-preopen enforcement; fuel + epoch deadlines; crash-quarantine (PluginCrashed)
              • Per-call overhead bench gates in CI (grammar round-trip < 5 µs p99 ~340 ns; no-per-frame-WASM dep-graph guard; wasm32-wasip2 in CI)
              • Every extension seam mirrored: picker, grammar (sync), completion, events, decorations, config, modes, host-services
              • Reference plugin: fuzzy-finder (validates picker primitive end-to-end; parity + overhead benched, not cut over)
              • Editor-side loading: loader ex-command, on-disk plugin discovery, init.rs-as-WASM config — Phase 8

              Multi-buffer + UI components (DESIGN.md §5.9) — Phase 6

              • Buffer abstraction + active-buffer routing; <C-o> / <C-i> walk across buffers
              • Pane tree + <C-w>{s,v,c,h,j,k,l,w,W} window-management chord grammar
              • Multiple Document buffers + :bn / :bp / :ls / :bd / :b N
              • File-tree buffer (:Tree path); multiple roots; :e folder defers to :Tree folder
              • Unified BufferRegistry: documents and trees in one keyspace; BufferFlags { listed, hidden }
              • Vim-style pane visuals: per-pane status line, separator, inactive dim — all :set ui.*
              • Hover popup (LSP K); inline completion popup (vertico-style)
              • Multibuffer / excerpt display: lattice-multibuffer crate, excerpt layout, virtual-row header providers, composed→source row map, scrolling, generic <CR> jump-to-source
              • Compilation mode: :compile/:recompile/:make any CLI tool, streaming *compilation* + headerline, 4-parser tool-agnostic error list (stdout+stderr; cargo test panics), :next-error/]qq, :error-list picker, *problems* view, <C-c> kill
              • *messages* buffer (synthetic Document, subsystem-owned streaming content)
              • Pane groups (D.4) — foundation for diff side-by-side, :set scrollbind, :windo
              • Virtual rows + DisplayMatrix — above/below-anchored inlays, fold summaries, header rows
              • Diff foundation: two-way and three-way hunk computation; gutter diff signs; side-by-side diff layout (partial)
              • Picker primitive as standalone buffer (file picker, project grep, diagnostics list — Phase 6)
              • Oil buffer (directory editing à la vim-oil) — lattice-listing
              • Terminal buffer on a real cross-platform PTY (portable-pty); T4 polish partly open (mouse), T5 plugin surface deferred to Phase 7+

              CI / engineering (DESIGN.md §8)

              • Workspace lint policy (unsafe_code = "deny", opt-in per module)
              • Criterion benches for runtime, motions, operators, search
              • Cross-platform CI matrix (Linux / macOS / Windows) + fmt + doc gates
              • Bench compile-check (catches bench-code rot per platform)
              • Bench baseline artifact recorded on every push to main
              • Keystroke→glyph ratchet (keystroke-ratchet job): records the p50/p95/p99 distribution at three file sizes and gates each against a committed baseline
              • Whole-suite bench regression gate (needs a stable runner; shared CI variance dwarfs signal)
              • Allocation-discipline checks on the render hot path (dhat-based)