|
| 1 | +# High-Frequency Command Architecture Plan |
| 2 | + |
| 3 | +Status: planning only. No command registry, keymap trie, effect queue, Lua |
| 4 | +keymap host, or command capability matrix is implemented by this document. |
| 5 | + |
| 6 | +## Goal |
| 7 | + |
| 8 | +Make Anvil's high-frequency editor actions fast, asynchronous where needed, |
| 9 | +and uniformly authorized without putting Lua on the keystroke path. |
| 10 | + |
| 11 | +The first slice is **not** `make`. Build commands are lower-frequency and |
| 12 | +higher-risk. The architecture should be proven on commands users invoke many |
| 13 | +times per minute: |
| 14 | + |
| 15 | +- `editor.undo`, `editor.redo` |
| 16 | +- `search.next` |
| 17 | +- `view.split`, `buffer.next` |
| 18 | +- `lsp.completion`, `lsp.hover`, `lsp.definition` |
| 19 | +- `editor.save` |
| 20 | +- `lsp.rename`, `lsp.code_action`, `lsp.format` |
| 21 | + |
| 22 | +## Non-goals |
| 23 | + |
| 24 | +- No Lua callback per key event. |
| 25 | +- No Lua direct mutation of the editor buffer. |
| 26 | +- No Go gateway round-trip for pure editor commands. |
| 27 | +- No `proc.spawn` blanket capability. |
| 28 | +- No `fs.write` implied by applying a workspace edit. |
| 29 | +- No make/build implementation in the first slice. |
| 30 | +- No replacement of the existing text model or selection model. |
| 31 | + |
| 32 | +## Current State |
| 33 | + |
| 34 | +- `crates/anvil-tui/src/commands.rs` dispatches Ex commands with string |
| 35 | + matching; it is not a command registry. |
| 36 | +- `crates/anvil-tui/src/keys.rs` resolves keys through hardcoded `pending` |
| 37 | + state and `match` arms; there is no trie. |
| 38 | +- `crates/anvil-tui/src/lsp_ui.rs` calls LSP operations directly; they are not |
| 39 | + command IDs or effects. |
| 40 | +- `crates/anvil-tui/src/workspace_edit.rs` applies edits to buffers and may read |
| 41 | + unopened files, but never writes files. |
| 42 | +- `crates/anvil-plugin-host` has no Lua/Luau VM yet. |
| 43 | +- `anvil-api` has `editor.register_command` as a method name but no command |
| 44 | + descriptor or effect DTO. |
| 45 | +- The Go gateway owns `sys.*`, `fs.*`, and `lsp.*`; the session currently |
| 46 | + starts with `-allow-read -allow-write` and a trusted, unsandboxed LSP path. |
| 47 | + |
| 48 | +This is a new kernel slice, not a small TUI refactor. |
| 49 | + |
| 50 | +## Architecture |
| 51 | + |
| 52 | +```text |
| 53 | +key event |
| 54 | + -> Rust keymap trie |
| 55 | + -> CommandId |
| 56 | + -> CommandDispatcher |
| 57 | + -> local capability check |
| 58 | + -> native execution OR bounded effect queue |
| 59 | + -> editor effects or gateway effects |
| 60 | + -> result/event with request id and revision |
| 61 | +``` |
| 62 | + |
| 63 | +Lua appears only in two places: |
| 64 | + |
| 65 | +1. startup declarations: keymap, aliases, command descriptors; |
| 66 | +2. custom command implementations that submit effects. |
| 67 | + |
| 68 | +Lua never appears in the per-key resolution loop. |
| 69 | + |
| 70 | +## 1. CommandRegistry |
| 71 | + |
| 72 | +Place the registry in a future `anvil-session`/`anvil-command` boundary, not |
| 73 | +`anvil-core`. `anvil-core::EditCommand` is a text/undo primitive, not a UI |
| 74 | +command. |
| 75 | + |
| 76 | +Each command descriptor should include: |
| 77 | + |
| 78 | +```text |
| 79 | +CommandId stable namespaced ID |
| 80 | +Aliases :w, :wa, etc. |
| 81 | +ArgsSchema command-line arguments |
| 82 | +ExecutorKind Native | Plugin |
| 83 | +Capabilities editor.write, fs.read, fs.write, sys.exec, ... |
| 84 | +Contexts select, insert, cmdline, overlay |
| 85 | +Priority interactive | background |
| 86 | +Concurrency immediate | single-flight | cancellable |
| 87 | +Result status, events, error |
| 88 | +``` |
| 89 | + |
| 90 | +Rules: |
| 91 | + |
| 92 | +- `:w` and `Space` menu entries reference the same canonical command ID. |
| 93 | +- Plugin commands are namespaced; they cannot override core command IDs. |
| 94 | +- Built-in and plugin commands share the same descriptor shape. |
| 95 | +- The registry exposes a bounded metadata query for command palette/menu; the |
| 96 | + key path stores an index, not a string lookup. |
| 97 | + |
| 98 | +## 2. Keymap trie |
| 99 | + |
| 100 | +- One trie per input context: select, insert, cmdline, overlay. |
| 101 | +- `Space` is a prefix node, not a special branch in `keys.rs`. |
| 102 | +- Trie leaves store a command index or `CommandId`. |
| 103 | +- Key resolution is allocation-free on the hit path, lock-free, and never |
| 104 | + calls Lua or JSON-RPC. |
| 105 | +- Trie snapshots are immutable and swapped atomically after config reload. |
| 106 | +- Prefix timeouts, counts, and operator-pending state stay in the TUI adapter |
| 107 | + or keymap state; they are not command implementations. |
| 108 | +- The `Space` menu is generated from the registry/trie, not a second hardcoded |
| 109 | + list. |
| 110 | + |
| 111 | +## 3. Lua/Luau configuration |
| 112 | + |
| 113 | +Lua runs outside the keystroke path and may only declare: |
| 114 | + |
| 115 | +```lua |
| 116 | +anvil.keymap.set("select", "<Space>f", "picker.open") |
| 117 | +anvil.keymap.set("select", "<Space>r", "lsp.rename") |
| 118 | +anvil.keymap.set("select", "K", "lsp.hover") |
| 119 | +anvil.keymap.set("insert", "<C-Space>", "lsp.completion") |
| 120 | +``` |
| 121 | + |
| 122 | +A Lua custom command submits effects: |
| 123 | + |
| 124 | +```text |
| 125 | +Lua handler |
| 126 | + -> ctx:submit_effect(effect) |
| 127 | + -> request id / status |
| 128 | +``` |
| 129 | + |
| 130 | +It cannot set `EditorState` directly. All editor changes go through the |
| 131 | +dispatcher and its revision checks. |
| 132 | + |
| 133 | +Startup configuration is atomic: parse, validate, resolve collisions, build |
| 134 | +trie, then swap. A bad mapping must not partially replace the previous map. |
| 135 | + |
| 136 | +## 4. Effect queue |
| 137 | + |
| 138 | +Effects are typed values, not arbitrary callbacks: |
| 139 | + |
| 140 | +- `editor.apply_edits` |
| 141 | +- `editor.set_selection` |
| 142 | +- `ui.notify` |
| 143 | +- `lsp.request` |
| 144 | +- `fs.read` |
| 145 | +- `fs.write` |
| 146 | +- `sys.exec` |
| 147 | +- `sys.fetch` |
| 148 | + |
| 149 | +Every invocation carries a request id, command id, source/plugin, deadline, |
| 150 | +cancellation token, and expected revision where applicable. |
| 151 | + |
| 152 | +Queue rules: |
| 153 | + |
| 154 | +- Pure editor commands execute immediately and do not queue. |
| 155 | +- LSP completion/hover are single-flight; a newer request cancels or replaces |
| 156 | + the older one. |
| 157 | +- Save is single-flight per buffer; a later save request coalesces with the |
| 158 | + pending one. |
| 159 | +- Bounded queues; explicit overflow behavior; interactive work is not starved |
| 160 | + by background work. |
| 161 | +- Stale results are dropped by path, request id, and revision. |
| 162 | +- Lua command results contain effects; they are authorized before being |
| 163 | + applied. |
| 164 | + |
| 165 | +## 5. Authorization |
| 166 | + |
| 167 | +Authorization has two layers: |
| 168 | + |
| 169 | +1. **CommandDispatcher (editor/session)**: checks the command's required |
| 170 | + capabilities and context before execution. |
| 171 | +2. **Gateway**: checks external effects (`sys.*`, `fs.*`, `lsp.start`) and |
| 172 | + applies sandbox/resource policy. |
| 173 | + |
| 174 | +Pure editor commands do not go through the gateway. The gateway is not the |
| 175 | +editor command registry. |
| 176 | + |
| 177 | +### Capability matrix |
| 178 | + |
| 179 | +| Command | Required capabilities | Disk write? | |
| 180 | +|---|---|---| |
| 181 | +| `editor.undo`, `editor.redo` | none | no | |
| 182 | +| `search.next`, `selection.*` | none | no | |
| 183 | +| `view.split`, `buffer.next` | none | no | |
| 184 | +| `lsp.completion`, `lsp.hover` | LSP request | no | |
| 185 | +| `lsp.definition` | LSP request, `fs.read` for target open | no | |
| 186 | +| `picker.open`, `tree.toggle` | `fs.read` | no | |
| 187 | +| `lsp.rename` | LSP request, `editor.write`, `fs.read` for unopened files | **no** | |
| 188 | +| `lsp.code_action` | LSP request, `editor.write`, `fs.read` for unopened files | **no** | |
| 189 | +| `lsp.format` | LSP request, `editor.write`, `fs.read` for unopened files | **no** | |
| 190 | +| `editor.save`, `:wa` | `fs.write` | yes | |
| 191 | + |
| 192 | +`WorkspaceEdit` application updates buffers and creates ghost buffers; it does |
| 193 | +not write files. `fs.write` is only for an explicit save command. |
| 194 | + |
| 195 | +## 6. High-frequency migration order |
| 196 | + |
| 197 | +### Phase A: pure synchronous commands |
| 198 | + |
| 199 | +Migrate `editor.undo`, `editor.redo`, `search.next`, `view.split`, and |
| 200 | +`buffer.next`. |
| 201 | + |
| 202 | +Acceptance: |
| 203 | + |
| 204 | +- Same editing behavior and undo boundaries. |
| 205 | +- No async delay or gateway call. |
| 206 | +- Key dispatch benchmark remains in the microsecond range. |
| 207 | +- Registry and trie tests cover conflicts and count/prefix handling. |
| 208 | + |
| 209 | +### Phase B: cancellable LSP queries |
| 210 | + |
| 211 | +Migrate `lsp.hover`, `lsp.completion`, and `lsp.definition`. |
| 212 | + |
| 213 | +Acceptance: |
| 214 | + |
| 215 | +- Completion cancels/replaces stale requests. |
| 216 | +- Hover has one in-flight request. |
| 217 | +- Results carry path and revision; stale results do not mutate state. |
| 218 | +- Key latency is independent of language-server latency. |
| 219 | + |
| 220 | +### Phase C: save |
| 221 | + |
| 222 | +Migrate `editor.save` and `:wa`. |
| 223 | + |
| 224 | +Acceptance: |
| 225 | + |
| 226 | +- Single-flight per buffer. |
| 227 | +- Explicit `fs.write`. |
| 228 | +- Failed save leaves the buffer dirty and reports the error. |
| 229 | +- No save writes from workspace-edit application. |
| 230 | + |
| 231 | +### Phase D: mutating LSP commands |
| 232 | + |
| 233 | +Migrate `lsp.rename`, `lsp.code_action`, and `lsp.format`. |
| 234 | + |
| 235 | +Acceptance: |
| 236 | + |
| 237 | +- Apply WorkspaceEdit with `editor.write` and optional `fs.read`. |
| 238 | +- No `fs.write` during apply. |
| 239 | +- Optional `:wa`/save is a separate authorized effect. |
| 240 | +- Overlapping/invalid edits do not partially mutate buffers. |
| 241 | + |
| 242 | +### Phase E: high-frequency pickers |
| 243 | + |
| 244 | +Migrate `picker.open` and `tree.toggle`, then address the known |
| 245 | +selection-scroll bug separately. |
| 246 | + |
| 247 | +### Phase F: Lua declarations and plugin commands |
| 248 | + |
| 249 | +Only after the plugin host owns a VM: |
| 250 | + |
| 251 | +- configuration parsing and atomic keymap swap; |
| 252 | +- plugin command descriptors and effect submission; |
| 253 | +- no per-key Lua callbacks. |
| 254 | + |
| 255 | +### Phase G: build commands |
| 256 | + |
| 257 | +Defer `build.run`/make until a build sandbox and explicit build policy exist. |
| 258 | +A generic `proc.spawn` capability is not acceptable. |
| 259 | + |
| 260 | +## 7. Contract requirements |
| 261 | + |
| 262 | +Before implementing, freeze: |
| 263 | + |
| 264 | +- Command ID names and namespaces. |
| 265 | +- Effect payload schema and error kinds. |
| 266 | +- Capability names, using the existing `sys.exec`, `sys.fetch`, `fs.read`, |
| 267 | + `fs.write` vocabulary unless a versioned migration is approved. |
| 268 | +- Context names for select/insert/cmdline. |
| 269 | +- Revision and cancellation semantics. |
| 270 | +- Conflict behavior for duplicate key mappings and duplicate command IDs. |
| 271 | + |
| 272 | +## 8. Verification plan |
| 273 | + |
| 274 | +- Unit tests for registry lookup, duplicate IDs, alias resolution, and |
| 275 | + capability metadata. |
| 276 | +- Trie tests for prefix collisions, timeouts, counts, and mode switching. |
| 277 | +- Dispatcher tests proving pure commands never call Lua or the gateway. |
| 278 | +- Effect queue tests for cancellation, single-flight, coalescing, stale results, |
| 279 | + and bounded overflow. |
| 280 | +- WorkspaceEdit tests proving no disk writes during apply. |
| 281 | +- Save tests proving `fs.write` is required and failure preserves dirty state. |
| 282 | +- A hot-path benchmark for key resolution. |
| 283 | +- Existing TUI behavior tests remain green. |
| 284 | + |
| 285 | +## Open questions |
| 286 | + |
| 287 | +- Command registry ownership: `anvil-session` versus a dedicated |
| 288 | + `anvil-command` crate. |
| 289 | +- Whether `lsp.*` methods become first-class commands or remain effect |
| 290 | + providers behind command IDs. |
| 291 | +- Whether context predicates are limited to declarative fields or permit |
| 292 | + plugin-defined predicates (the latter risks the hot path). |
| 293 | +- Whether the gateway should expose a lightweight local policy-decision API, or |
| 294 | + the dispatcher and gateway share a capability table without RPC. |
| 295 | +- Exact priority policy for interactive LSP requests versus background index |
| 296 | + work. |
0 commit comments