| 5.5 | 🟡 in progress | Dispatch extraction (App::apply → Editor::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::EchoMarks → Editor::do_list_marks, Effect::EchoRegisters → Editor::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_yank → Editor::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_changed → Editor; 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 _outcome → outcome, add handle_renderer_signal drain loop matching ThemeChanged → rebuild_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.X → self.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 bool — true 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_buffer → Editor; 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_name → Editor::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_modes → Editor; 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_state → Editor (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-Editor — editor.scroll + editor.visible_highlights reads/writes; no RendererSignal::EditsApplied needed) + private shift_spans_within_line → Editor; App-side becomes 1-line delegate then deletes in E.7.2 once publish_document_changed migrates. E.7.2 publish_document_changed → Editor; 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_oil → Editor; 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.7 | ⛔ | lattice-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 progress | GPUI 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.9 | ✅ | lattice-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) | dropped | Superseded 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) | dropped | ratatui'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.last | ⛔ | Flip default to GPUI. Separately scheduled, after parity + a release cycle as opt-in. | 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. | Slice | Status | Commit | What landed |
|---|
| 1 | ✅ | 8d8c5b2 | LSP 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. | | 2 | ✅ | a32b4eb | .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. | | 3a | ✅ | b6cd547 + e3d4497 + 0ffd767 + f9310e2 | RenderState 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.0 | ✅ | 7f51492 | document_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.1 | ✅ | aaf5980 | inlay_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.2 | ✅ | de49271 | semantic_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.3 | ✅ | f574ede | code_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.4 | ✅ | 0762596 | document_link + document_color off the UI thread. Two more caches by the template. LspRenderState gains both. | | 3b.5 | ✅ | f25f9a2 | pull_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 | ⛔ planned | — | Signal-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 | ⛔ planned | — | Picker + completion + messages drains. User-interactive state machines; pattern same as 3b.* but the state types are richer. | | 3c.0 | ✅ | f896ee6 | Editor-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.1 | ✅ | ec48195 | ActiveDocumentRenderState 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 prep | ✅ | 750e51f | App::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.A | ✅ | 578d557 | App.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.B | ✅ | 3e3347f | render.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.1 | ✅ | 91eefb4 | runtime.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.C | ✅ | d51ac6a | Typed 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.D | ✅ | 933837e | highlights.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.E | ✅ | 9fe7337 | Combined 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.F | ✅ | 51c173f | EditorActorHandle 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-flight | — | Remaining 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 progress | — | Editor 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.A | ✅ | audit only | Renderer ↔ 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.1 | ✅ | — | Panes + 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.2 | ✅ | — | Active-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.3 | ✅ | — | Picker + 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.4 | ✅ | — | LSP 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.5 | ✅ | — | TranslatorRenderState — 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.6 | ✅ | — | Lifecycle + 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.C | ✅ | — | Renderer 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.D | ✅ | verification only | Sync-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.3 | ✅ | — | Mid-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.cleanup | ✅ | — | Warning 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.swap | ✅ | — | STRUCT 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.5j | ✅ | — | Renderer 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.5i | ✅ | — | TUI 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.5h | ✅ | — | app/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.5g | ✅ | — | app/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.5e | ✅ | — | Methodify-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.5d | ✅ | — | Rule-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.X → panes().tree.X (4 sites in help/lifecycle), buffers.X → buffers().registry.X (4 sites in lifecycle/dispatch), option_cache.X → ad().option_cache.X (7 sites in options), active_buffer → ad().buffer_kind (4 sites in dispatch), document.snapshot() → ad().snapshot.clone() (5 sites in picker/search/help/Debug + 7 in picker_sources), popup_buffer → popup().buffer_id (5 sites in help/popup), modal → ad().modal (3 sites in cmdline + Debug), should_quit → render_state.load().lifecycle.should_quit (2 sites in dispatch + GPUI peer), host_theme → render_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_buffer → app.ad().buffer_kind (6 sites), app.editor.command_line → app.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.5c | ✅ | — | ast-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_editor → mutate_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.swap | ⏸ | attempted + reverted | Actor-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.5 | ✅ | — | Heavy-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.4 | ✅ | — | Boot + 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.2 | ✅ | — | Routing 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.1 | ✅ | — | Proof-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.0 | ✅ | — | Actor 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. | Slice | Status | What lands |
|---|
| S1 cell substrate | ✅ landed | New 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 | ✅ landed | Tokio 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 | ✅ landed | CellsRenderState 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 | ✅ landed | cells_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 | ✅ landed | Syntax 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 | ✅ landed | Hash 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 | ✅ landed | dispatch.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 | ✅ landed | CellsRenderState 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 | ✅ landed | Chunked 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 | ✅ landed | pick_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 | ✅ landed | New 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 | ✅ landed | tokio 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 | ✅ landed | TUI 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 | ✅ landed | Cell::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 | ✅ landed | TUI 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 progress | Sliced 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 | ✅ landed | CellMatrix::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 | ✅ landed | 7 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 | ✅ landed | apply_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 | ✅ landed | apply_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 | ✅ landed | splice_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 | ✅ landed | compose_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 progress | Sliced 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 | ✅ landed | lattice-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 | ✅ landed | EditorElement 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_runs → shape_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 | ✅ landed | cell_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 | ✅ landed | shape_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 progress | Replaces 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 | ✅ landed | GlyphResolver 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 | ✅ landed | paint_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 | ✅ landed | Survey 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 | ✅ landed | paint_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 | ✅ landed | Survey 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 | ✅ landed | Stripped [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). | Slice | Status | What lands |
|---|
| live-pkr.1 | ✅ | Trait + 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.2 | ✅ | App-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.3 | ✅ | GrepSource 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.4 | ✅ | Docs. ../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). 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. Modal states | State | Status | Anchor | Notes |
|---|
| Normal | ✅ | §5.2 | Plus block cursor in TUI | | Insert | ✅ | §5.2 | Plus bar cursor | | Visual (Charwise) | ✅ | §5.2, B.1 | Selection extends, operators on Range::Selection | | Visual (Linewise) | ✅ | §5.2 | | | Visual (Blockwise) | ✅ d/y/c/I/A/>/< + paste | §15:18 | Ctrl-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.2 | Resolved through translate_normal pending state | Command (:) | ✅ | §5.9.10 | Rich minibuffer scope is partial; full spec is post-Phase-1 | Search (/, ?) | ✅ | §5.9.10 | Live preview wired (hlsearch on every keystroke); fancy-regex backend | Replace (R) | ✅ | §5.2 | Backspace-restore wired | Motions (Reflex-class) | Motion | Key | Status | Anchor |
|---|
| char_left / char_right | h, l | ✅ | §5.2.2 | | line_up / line_down | k, j | ✅ | §5.2.2 | | line_start / line_end | 0, $ | ✅ | §5.2.2 | | first_non_blank | ^ | ✅ | §5.2.2 | | word_forward | w | ✅ | §5.2.2 | | word_backward | b | ✅ | §5.2.2 | | word_end | e | ✅ | §5.2.2 | | WORD_forward / backward / end | W, B, E | ✅ | Whitespace-delimited variants | | paragraph_forward / backward | }, { | ✅ | §5.2.2 | | sentence_forward / backward | ), ( | ✅ | | | goto_first_line / goto_last_line | gg, G | ✅ | §5.2.2 | | find_char_forward / backward | f, F | ✅ | §5.2.2 | | till_char_forward / backward | t, T | ✅ | §5.2.2 | | find_repeat / find_repeat_reverse | ;, , | ✅ | | | viewport_top / middle / bottom | H, M, L | ✅ | App-level (needs viewport_height) | | word_search_forward / backward | *, # | ✅ | §B.3 informally | | match_bracket | % | ✅ | App-level | | jump_history_back / forward | Ctrl-O, Ctrl-I | ✅ | §5.1.1 unified ring (filtered to AutoJump+PluginPush) | | mark_history_back / forward | g;, g, | ✅ | §5.1.1 unified ring (filtered to NamedMark) | | page_down / page_up | Ctrl-F, Ctrl-B | ✅ | App-level | | scroll_line_up / down | Ctrl-Y, Ctrl-E | ✅ | App-level | | half_page_down / up | Ctrl-D, Ctrl-U | ✅ | Hardcoded count 10 | | mark jumps | 'a, `a | ✅ | §5.1.1 | Operators (Reflex-class for sync prelude) | Operator | Key | Status | Anchor |
|---|
| delete | d, dd, D | ✅ | §5.2.2 | | change | c, cc, C | ✅ | §5.2.2 | | yank | y, yy, Y | ✅ | §5.2.2 | | indent_left | < | ✅ | §5.2.2 | | indent_right | > | ✅ | §5.2.2 | | upper | gU | ✅ | §5.2.2 | | lower | gu | ✅ | §5.2.2 | | toggle_case | g~ | ✅ | §5.2.2 | | filter | ! | ⛔ | Subprocess pipe; depends on :!cmd (§B.6) | | format | gq | ⛔ | Depends on plugin / formatter | | join_lines | J, gJ | ✅ | App-level (not a grammar operator) | Text objects | Text object | Key | Status | Anchor |
|---|
| inner_word / around_word | iw / aw | ✅ | §5.2.2; alphanum + _ run | | inner_WORD / around_WORD | iW / aW | ✅ | Whitespace-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 / around | i" / a" | ✅ | | | inner_quote_sgl / around | i' / a' | ✅ | | | inner_quote_btk / around | i` / a` | ✅ | | | inner_paren / around | i( / a( | ✅ | | | inner_bracket / around | i[ / a[ | ✅ | | | inner_brace / around | i{ / a{ | ✅ | | | inner_angle / around | i< / a< | ✅ | <…> pair; reuses text_object_inner_brackets / _around_brackets. Both < and > resolve to the same target. | | inner_tag / around | it / at | ✅ | XML/HTML tags | | inner_paragraph / around | ip / ap | ✅ | | | inner_sentence / around | is / as | ✅ | | Counts, registers, ranges, marks, macros, dot-repeat | Feature | Status | Anchor |
|---|
| 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 | Feature | Status | Anchor |
|---|
/ ? n N regex search with wrap | ✅ | §5.9.10; backed by fancy-regex (DFA + bounded NFA fallback for backrefs) | * / # word-search | ✅ | Word is regex-escaped before compile so literals containing . * ( etc. don't trigger metachars | | Search highlight in buffer | ✅ | §5.6.2 | :s/PAT/REPL/[g] substitute | ✅ | Full 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 timer | ⛔ | the 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. | Command | Status | Anchor |
|---|
| :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_set ⇒ config.parse_and_set_command ⇒ apply_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 | 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. | Capture mechanism | Used for | Status | Forgery resistance |
|---|
#[track_caller] on register_* | built-in command registrations in builtins.rs / ex_commands.rs -- zero call-site burden | ✅ implemented | compiler-captured, caller cannot supply or override | keymap_entry! declarative macro | static keymap rows (per-row file!() + line!()) | ✅ implemented | macro is the only construction path; source field is pub(crate) | | Trusted subsystem builds value | config 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 | ✅ implemented | invisible 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: | Stage | Trait | Built-ins |
|---|
| Generation | CandidateGenerator | gen:commands, gen:files (host-state generators in lattice-ui-tui) | | Matching | CandidateMatcher | match:prefix (default), match:substring, match:fuzzy | | Ranking | CandidateRanker | rank:score (default), rank:alphabetical | | Annotation | CandidateAnnotator | anno: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: | Capability | Status | Notes |
|---|
<Tab> opens completion popup | ✅ | Slot-aware: command-name slot uses gen:commands; arg slot uses arg_spec.completion. | <Tab> advances candidates while open | ✅ | wraps at end | <S-Tab> moves backward | ✅ | wraps at start | <CR> accepts selected | ✅ | replaces [replace_start, end) with the candidate's text | <Esc> two-stage dismiss | ✅ | first Esc closes popup; second cancels cmdline | | Typing dismisses popup | ✅ | re-trigger with Tab for fresh candidate set | <C-h> describe under cursor | ✅ | hybrid: word-at-cursor describes self if registered; else parent command at arg:<name> anchor | <C-u> clear cmdline | ✅ | also dismisses popup | <C-w> delete trailing word | ✅ | strips whitespace then last token | | Vertico-style popup render | ✅ | bordered, anchored BELOW the : prompt (cmdline shifts up to make room), selected row highlighted, matched byte ranges painted, annotations right-aligned | | Alias-preferred candidate text | ✅ | gen: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 | ⛔ deferred | needs full cmdline-cursor refactor; v1 cursor stays at end | <C-r> register paste | ⛔ deferred | needs Pending::AfterCommandLineRegister substate | Completion inside :s/.../.../ body | ⛔ deferred | DelimiterBody 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: | Markup | Resolution |
|---|
[[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: | Capability | Status | Notes |
|---|
| Buffer-backed help (rope content) | ✅ | HelpBuffer.content: Buffer | | Link markup defined + parsed | ✅ | parse_help_links returns Vec<HelpLink> with byte ranges | | Help formatters emit links | ✅ | :describe-key, :apropos, :keymap reference cross-targets | | Display: Popup overlay | ✅ | transient 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 height | ✅ | App::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 registry | ✅ | The 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 renderer | ⛔ | renderer ignores links today | Follow-link motion (e.g. <CR> on link) | ⛔ | needs tree-sitter help grammar + link motion | | Help major mode + tree-sitter grammar | ⛔ | post-Phase-3-extension; sections / code-blocks / link-targets | SourceLocation on CommandSpec | ⛔ | needs register_* API extension; powers [[file:...]] auto-emit | :source-of <command> | ⛔ | depends on SourceLocation | :describe-key | ✅ | keymap registry §5.2.3 -- see below | :keymap | ✅ | full default keymap, grouped by mode | :describe-option | ⛔ | needs typed options registry §5.12 | :describe-event | ⛔ | needs event bus §5.10 | :describe-mode | ⛔ | needs 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. | Concern | Status | Anchor |
|---|
| 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. | 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 transformation | n/a (single-pane v1) | §5.6.8 | 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 | Concern | Status | Anchor |
|---|
| 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: | Benchmark | DESIGN 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.c —
TerminalInsertMode 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_mode → key_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:tabterminal → AppEffect::TerminalSpawnInNewTab → do_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_cell → RenderState.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.panes ⇒ CacheHit (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::DiffOff → Effect::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 DiffSession ⟺ diff-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: - 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). - 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]. - 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. 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.- 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. - 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. - 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: 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.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.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.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: 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).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).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).
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.NoPeerBuffer → set_message(EchoLevel::Error, "dp: baseline is not a buffer; use :write").Nothing → silent.
- 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_edit — tokio::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: 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.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.- 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=None — resolve_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::DeletionBlocklattice-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 small lattice-vcs crate over gix. ✅ 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 → StaticSourceOnDiskBaseline → OnDiskSourceBufferBaseline + 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_members → diffthis_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. 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 DocumentHandle → RopeDocumentHandle. 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.3 — Deferred 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::SearchTrigger → Action::SearchTrigger → Editor::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::SearchJumpToSource → Action::SearchJumpToSource → Editor::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::SearchRefresh → Action::SearchRefresh → Editor::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 BindingMode → lattice-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 -- 9/12 shipped + 1 partial. Phase 4.3 -- 3/9 shipped. - ✅ 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 queued behind it). - ✅ 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 - 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 2 —
DiagnosticsLayer 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 3 —
SyntaxActor. 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 5 —
path_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 7 —
FrameView 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. 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. 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. :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(®istry) → 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: relativenumber ⇒ number=true, foldmethod ⇒ recompute_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 ~/.cache/lattice/); 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. - 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.
- Help major mode + tree-sitter grammar — defines sections, link-targets, code-blocks. Needs the help mode registered as a major mode, which depends on the modes registry (Phase 8) but the grammar can be drafted earlier.
- 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. - Per-
LatencyClass deadline timers — Reflex commands observe a 2 ms deadline, Display commands 10 ms, both via the cancellation token already plumbed. - 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. - Render-hot-path alloc discipline — dhat-based assertion that steady-state frames produce no allocations.
§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. | Crate | Tests |
|---|
| lattice-protocol | 39 | | lattice-core (incl. integration) | 128 | | lattice-grammar | 191 | | lattice-keymap | 99 (2026-06-16) | | lattice-completion | 124 | | lattice-config | 118 | | lattice-syntax | 82 | | lattice-theme | 53 (2026-06-29) | | lattice-runtime | 43 | | lattice-lsp | 183 | | lattice-picker | 48 (2026-06-29) | | lattice-ui-tui | 1507 (2026-06-29) | | lattice-host | 654 (2026-06-29) | | lattice-ui-gpui | 25 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): | Slice | Title | Status |
|---|
| N.1.0 | textobjects.scm (.outer) + scope_at_cursor (lattice-syntax) | ✅ | | N.1.1 | create_narrow_view + NarrowMinorMode + :narrow {range} + :widen | ✅ | | N.1.2 | :narrow from Visual selection / cursor paragraph | ✅ | | N.1.3 | The zn narrow operator (operator-pending; composes with any motion/object) | ✅ | | N.1.4 | Tree-sitter text objects as first-class grammar objects (ScopeResolver seam + af/ac/aa/al/aC + .inner) | ✅ | | N.1.5 | Transparent stacked narrow — one-hop invariant to RopeDocumentHandle | ✅ | | N.1.6 | Comment 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.mode → modes: &[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. Dashboard (DB.1–DB.7 ✅ complete; DB.8 📝 deferred post-v1, 2026-07-03) 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. | Slice | Title | Status |
|---|
| DB.1 | crate + registry + fragment + config | ✅ | | DB.2 | *dashboard* buffer + dashboard-mode + :dashboard | ✅ | | DB.3 | dashboard.* theme elements | ✅ | | DB.4 | branding block (art, colour, gutter-centring, help-mode, cursorline) | ✅ | | DB.4-cleanup | revert redundant VirtualRowAlign (gutter supersedes it) | ✅ | | DB.4-gpui | per-token virtual-row scaling (F.3) + scaled wordmark; side-by-side custom-paint lockup deferred (post-1.0 w/ image) | ✅ | | DB.5 | startup gating + mode-owned trigger (Startup typed event, ConfigRegistry Phase-A hoist) | ✅ | | DB.6 | full override + recompose triggers (dashboard.source, Event::OptionChanged subscription) | ✅ | | DB.7 | benches + ledger (this entry) | ✅ | | DB.8 | plugin sections + body custom roles | 📝 deferred — gated on the plugin host + theme-remap seam | 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. | Slice | Title | Status |
|---|
| TSM.0 | design fragment + slice plan | ✅ | | TSM.1 | grammar seam — scope_toward + MotionContext.scope_resolver | ✅ | | TSM.2 | SyntaxSnapshot::scope_toward real tree walk | ✅ | | TSM.3 | register_syntax_motions (16 MotionSpecs) | ✅ | | TSM.4 | host wiring — boot registration + 16 chord bindings | ✅ | | TSM.5 | bench + 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. | Area | Status | Notes |
|---|
| 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) | ✅ kept | The 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 list —
ParserRegistry::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>>> → InboundBus → AppEffect::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-kill → CompilationService::kill(). - Decorations — severity gutter marks (native
render_state → DecorationCtx → Mode::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 Deferred (⛔): CM.5 ANSI-SGR → decorations; CM.6 WASM-contributable parsers (Phase 7 plugin host). 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.
|
| |
|