# Changelog

All notable changes to this project will be documented in this file. The format
is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This
project adheres to [Semantic Versioning](https://semver.org/) once it reaches
0.1.0; the 0.0.x series is a churn phase where breaking changes may land on
patch bumps.

## [Unreleased]

## [0.41.3] - 2026-08-12

### Added

- **Filetype detection for files with no known extension.** New
  `LanguageDirectory::detect` seam — the single place the editor resolves "what
  language is this file", in precedence order: known basename (`Makefile`,
  `CMakeLists.txt`, `Dockerfile*`, `PKGBUILD`, `Gemfile`, …) → extension →
  `vim:`/`vi:`/`ex:` modeline `ft=` / `filetype=` → shebang
  (`#!/usr/bin/env bash`, incl. `env -S` / `-u VAR` and version-suffixed
  interpreters like `python3.11`). The app routes every open, reload, rename and
  `:set filetype=` — plus every language-keyed consumer (grammar attach, LSP
  attach, status-line filetype label, comment lead) — through the seam: an
  extensionless file with `#!/usr/bin/env bash` or `# vim: ft=bash` now gets
  syntax highlighting, the `filetype` option, commentstring and LSP attach. A
  modeline `ft=` beats a shebang (vim parity: an explicit author-stated type
  outranks the heuristic); the basename step runs before the extension lookup so
  `meson.build` resolves to `meson`, not the `systemd` grammar's catch-all
  `build` claim. (vim lets a modeline beat the extension too; hjkl keeps the
  extension step first — the two rarely disagree, and a `.py` file staying
  `python` is the less surprising outcome.) The modeline scan is bounded: only
  the first and last `'modelines'` lines (default 5) are examined, at most the
  first 500 chars each, and later lines win (nvim-verified) — the whole-file
  cost of detection is O(10 lines), independent of file size, and no line table
  is allocated.

### Performance

- **Blockwise and indent/reflow operations no longer rebuild the whole buffer.**
  `transform_block_case` (`gu`/`gU`/`g~`/`g?` over a block), block `r`,
  `visual_replace_char`, `gq`/`gw` reflow, and the `>>`/`<<`/`==` indent family
  collected the entire document into a `Vec<String>` per op; they now read and
  write only the touched row range through per-row edits (one bounded Replace
  where the row count changes). Measured on a 100k-line buffer (`--release`,
  same probe both sides): a block case-op ~58 ms → ~0.1 ms, `gq` ~65 ms → ~0.04
  ms, peak RSS 43 MB → 27 MB.
- **The change log is now opt-in, cutting a payload copy from every edit.**
  `Editor::take_changes`' log was recorded on every `mutate_edit` — building a
  `Vec<EngineEdit>` with a payload clone per edit — but nothing in hjkl or its
  sibling projects reads it (drain sites are tests). Recording now defaults OFF
  (`View::set_change_log_enabled(true)` opts in), so the edit funnel skips the
  clone and Vec entirely. Measured (`--release`, same probe both sides, fresh
  process): a paste-heavy workload's peak RSS 651 MB → 456 MB, and a 200k-edit
  keystroke burst 45 MB → 18 MB.
- **`:s` materializes and splices only the substituted rows.** A cursor-line
  `:s/foo/bar/` on a huge file used to rebuild the whole buffer
  (`rope_to_lines_vec` + join + `replace_all` + an all-row mark rebase); it now
  touches only `start..=clamp_end` through a bounded `Edit::Replace`, rebasing
  marks over just the in-range rows. O(range) instead of O(N).
- **The picker stopped rebuilding and re-sorting the candidate list every
  keystroke.** It fetched each label twice and built per-char spans for all
  filtered rows on every draw (~1000 `label()` calls + ~1500 allocs per
  keystroke); now only the visible window's rows are built, one `label()` per
  row with adjacent same-style chars merged into borrowed span slices, and
  `refresh()` reuses one scratch buffer instead of scoring and fully sorting all
  N candidates. Rendered output verified byte-identical.
- **Fold-hidden queries are O(log F) instead of O(F).** "Is this row hidden"
  scanned the whole fold list per row on per-keystroke and per-frame paths; a
  merged-interval `FoldIndex` now answers with one `partition_point` across the
  cursor/snapshot/effective-height/explorer-overlay fold queries.

### Security

- **`--nvim-api` / `--embed` filesystem confinement is no longer bypassable.**
  `:e` / `:cfile` (and `l*` variants) now run the same `check_fs_path` +
  `resolve_under` gate as `:r` and the write path, so a remote client cannot
  read paths outside the working directory (including through symlink escapes),
  and `:cd` is refused while the policy is active so the write-side confinement
  root cannot be moved. `:w`/`:e`/`:r` were already gated.
- **Restricted RPC modes no longer fetch grammars from the network.** Opening a
  file over the RPC could previously trigger a clone+compile+`dlopen` of
  upstream tree-sitter source on demand. In `--nvim-api` / `--embed` mode a
  grammar miss now resolves to plain text instead (cache and on-disk installed
  grammars still load); the interactive TUI keeps remote loading.
- **RPC-mode file reads are capped at 256 MiB.** The `--embed` / `--nvim-api`
  read paths took file sizes from an untrusted client and read unbounded,
  letting a client write a multi-GB file into the cwd and force a matching
  allocation. Fresh-open, bare-`:e` reload, `:checktime`, embed `:e` and
  `:DiffOrig` reads now cap at 256 MiB while the fs policy is active
  (`HJKL_RPC_READ_CAP` overridable); the TUI keeps unbounded reads.
- **`nvim_buf_set_name` + bare `:e` / `:checktime` can no longer read arbitrary
  files.** An RPC-supplied path was stored verbatim and reloaded with no policy
  check, bypassing `restrict_fs()`; the stored slot filename now runs
  `check_fs_path` + `resolve_under` before either read seam (absolute-inside-
  cwd paths still open).
- **`:DiffOrig` reads are gated under the RPC fs policy.** A buffer renamed via
  `nvim_buf_set_name` to an outside path leaked its bytes into the diff split in
  `--nvim-api` mode; the read now goes through the same `resolve_read_path` gate
  as bare-`:e` / `:checktime`.
- **`:Anvil` host commands are refused while the shell policy is off.** Anvil
  installs download archives and run package-manager build scripts (cargo / npm
  / pip / go), making the surface shell-out in disguise — but it had no policy
  check, so an untrusted `--nvim-api` client could trigger network fetches and
  native builds. The whole surface (install / uninstall / update / picker) now
  errors when `shell_disabled()`, matching the `:!` / `:make` / `:grep` gates.

### Fixed

- **A fold pass can no longer block the UI thread on a grammar fetch.** The
  injected-folds pass resolved injected-region grammars through
  `LanguageDirectory::by_name`, which clone+compiles (and can network-fetch) a
  grammar on first use — a per-frame fold pass could be the trigger. It now uses
  the new cache-only `by_name_cached`; an injected region whose grammar is not
  yet loaded simply contributes no folds until the highlight path (or async
  loader) has loaded it, which is at most a frame later.
- **Undo files whose `last_child` points at a non-child no longer misdirect the
  first `<C-r>` after load.** `from_serializable` validated only the pointer's
  range; a hand-edited or truncated file could name any in-bounds node, so the
  first redo from that node walked into the wrong subtree. A `last_child` that
  does not name one of the node's own children is now cleared on load (repaired,
  not rejected — consistent with the existing root→current chain repair).
- **`\d` / `\s` / `\w` now mean vim's ASCII classes, not rust-regex's
  Unicode-wide ones.** `:s/\d/` used to match non-ASCII digits (U+0663) and `\s`
  matched any Unicode whitespace; both now follow vim: `\d` = `[0-9]`, `\s` =
  `[ \t]`, `\w` = `[0-9A-Za-z_]`, with `\D`/`\S`/`\W` negated the same way.
  Inside `[...]` all eight class escapes are now the LITERAL set {`\`, letter} —
  measured against neovim 0.12.4, which also corrects the earlier `[\a]`/`[\A]`
  "alphabetic range" translation, which nvim does not do.
- **Bracket and tag text objects no longer collapse a blockwise selection.**
  `ib` / `ab` / `iB` / `it` in `<C-v>` blockwise visual used to collapse to
  charwise and flip/outdent whole lines; neovim 0.12.4 keeps the block exactly
  as the block motion made it — the object is found but the selection does not
  change (`<C-v>jib~` flips the same cells as `<C-v>j~`). Word objects still
  extend the block. Pinned by four new corpus cases measured against neovim.
- **`e`/`E` word-end motions wrap line endings like vim.** The same-class walk
  jumped past a line's last char onto the next row (folding the next line's word
  into the run), and the whitespace skip treated an empty line / the virtual NUL
  cell as a hard stop. Both fixed; `ge`/`gE` were already correct. Pinned by 7
  unit tests and 14 live-nvim-verified oracle cases across `e`/`E`/`ge`/`gE` and
  line breaks.

- **`:retab` no longer panics (divide-by-zero) after a `# vim: ts=0` modeline.**
  The settings-sourced tabstop is clamped to ≥1 like every other consumer;
  `:retab 0` was already rejected.
- **A numeric range address past EOF now errors `E16: Invalid range` instead of
  silently clamping** — `:5d` on a 3-line buffer deleted the last line; nvim
  errors and deletes nothing. The bare `:N` goto still clamps, and derived
  addresses (`.`, `$`, marks, search) are unchanged.
- **`hjkl-tabs` `TabBar::close` keeps focus when closing a non-active tab** (it
  used to focus the tab at the removed index); focus only moves when the closed
  tab was active.

## [0.41.2] - 2026-08-06

### Changed

- **Dependency updates** (lockfile-only; no manifest changes): direct bumps clap
  4.6.5, ignore 0.4.33, similar 3.1.2, toml 1.1.4, plus transitive bumps (cc,
  futures, hyper, rustls, syn, time, …). All other direct deps were already at
  latest stable.

## [0.41.1] - 2026-08-06

### Fixed

- **`n` (and `*`) no longer gets stuck on a match starting with a multi-byte
  char.** `search_forward`'s skip-current step advanced one _byte_, and
  `pos_at_byte` rounds a mid-char byte down to the char's start, so on `éé` the
  first `n` re-found the current match forever. It now steps by the full char
  width.
- **`N` wraps to the last match when the current match starts at buffer
  byte 0.** The skip-current branch returned `None` for a match at byte 0 and
  never wrapped; it now queries `find_prev` from end-of-buffer.
- **`w`/`W`/`e` no longer land on ropey's phantom trailing row** of a
  newline-terminated buffer. The word-motion step helpers bounded the wrap by
  the raw row count; they now use `content_row_count`, and `end_of_buffer`
  anchors one past the last real char, so `yw` still yanks through it.
- **`:s` with a newline in the replacement now rebases marks, global marks,
  folds and the jumplist below the change.** The substitute applied content via
  `replace_all`, which never reaches `mutate_edit`'s `shift_marks_after_edit`,
  leaving positions stale.
- **Ex range `$` and `%` resolve to the last real content line** instead of
  ropey's phantom trailing row on newline-terminated buffers — `:$d`, `:$-1d`,
  `:$y` and `%` no longer act one line past vim's last line.
- **`ensure_cursor_visible` no longer underflow-panics** when another view
  shrank the shared buffer and this view's cursor is stale: the soft-wrap scroll
  loop is bounded by the clamped cursor row.
- **Visual-block `A` with nothing typed no longer leaves phantom padding
  spaces** in the buffer. The eager top-row pad is removed byte-for-byte on an
  empty Esc (never via `ed.undo()`, which would have left the pad on the redo
  stack), and the no-op undo boundary is consumed.
- **`s` (substitute char) now writes the deleted text to the unnamed register**,
  matching `x`/`X`, so `p` after `s` pastes the substituted char.
- **The first buffer in `--nvim-api` mode gets id 1, not 0.** A 0 handle means
  "current buffer" on the wire, so an id-0 initial buffer could never be
  refocused by its own handle.
- **`nvim_buf_get_text` rejects an inverted range** (start after end, including
  same-row `start_col > end_col`, which previously panicked on the slice)
  instead of silently returning `[]`.
- **Comment-marker highlight spans no longer start or end mid-multi-byte-char**
  when the char before/after a marker word is non-ASCII; both the bytes and rope
  paths snap to UTF-8 boundaries.
- **`HexColorPass::apply_range` now checks the left boundary at the range
  start**, matching `apply_range_rope`.
- **Indent guides and diagnostic overlays account for diff-filler rows** and no
  longer paint N rows too high in a diff pair.
- **Markdown tables never render wider than the viewport.** The column elision
  kept a real column when `max_cols` was 1 and the per-column minimum was
  applied after scaling, so a 2-column table could render 13 cells wide in a
  10-cell viewport.
- **Default-scope `:g` / `:v` no longer iterate ropey's phantom trailing row**
  of a newline-terminated buffer. The no-range arm scanned the raw row count; it
  now uses the same content row count the `$`/`%` range fix does, so `:g/^$/d`
  on `"a\nb\n"` deletes nothing instead of stripping the final newline. The
  shared helper is now `hjkl_engine::motions::content_row_count`, replacing
  hjkl-ex's inlined copy.
- **`:s` now emits the same host records a `mutate_edit` edit would**: one
  coarse whole-buffer Replace on the change log plus the content-reset flag, so
  syntax reparse, LSP and diff consumers observe the substitute. The substitute
  path swapped content via `replace_all`, which emitted neither.
- **The `:s///c` confirm prompt can no longer panic the editor.** The match
  highlight and the per-match cursor jump read the live buffer with offsets
  collected when the prompt started; an external autoreload or a mid-confirm
  buffer switch could make a row or byte offset stale and panic in
  `rope_line_str` / a byte slice (the render path fired with no keypress). Both
  sites now clamp the row and fall back to the line's char count, mirroring the
  guarded initial jump.
- **Case operators (`gU`/`gu`/`g~`/`g?`/`gUU` and visual `U`/`u`/`~`) no longer
  touch the clipboard or registers.** The transform routed through the cut
  funnel, pushing the pre-transform text to the OS clipboard and the
  small-delete/numbered/named registers; vim's case operators record nothing.
- **Explorer file↔dir type changes at an unchanged path now stick.** The
  reconcile emitted Trash + Create for the same path in one batch and the
  create's by-name trash restore moved the old entry back, silently undoing the
  change; type-change creates are now fresh, with the old entry left in the
  trash.
- **vim pattern escapes `\a`/`\A`/`\Z`/`\e` translate correctly.** They passed
  through to rust-regex, where `\A` is a start-of-text anchor (`:s/\A/x/`
  inserted at buffer position 0), `\a` is Bell, and `\Z`/`\e` were rejected.
  They now mean vim's alphabetic, non-alphabetic, case-switch and ESC. An
  escaped `]` inside a character class (`[a\]b]`) is also a literal member again
  instead of a "bad pattern" error.
- **`:0r file` reads the file before line 1** like vim, instead of clamping the
  literal 0 to line 1 and inserting after it.
- **Linewise deletes on buffers using ropey's non-`\n` line separators (bare
  `\r`, U+000B, U+000C, U+0085, U+2028, U+2029) undo exactly.** The inverse edit
  rebuilt the removed text joined with `\n`, fabricating a phantom line break on
  undo; it is now the exact removed span.
- **LSP diagnostic underlines and the past-end-of-line cursor cell land on the
  right screen cells** on lines with tabs or wide characters; both used char
  columns as cell offsets.
- **Grammar installs keep capture-form `(#set! @cap ...)` directives**, which
  the runtime pre-extracts into per-capture metadata (e.g. `metadata["url"]` on
  link spans); the installer was stripping them, dead-featuring pre-extraction
  for every installed grammar. The compiled-artifacts cache is also keyed by the
  grammar `.so` identity, so a grammar rev bump with unchanged queries no longer
  reuses a `Query` compiled against the old symbol table.
- **`hjkl-fs-watch`: rename merging no longer bypasses the path filter or
  fabricates pairs.** A rename whose destination fails the filter degrades to a
  removal, and an unrelated `Create` inside the debounce window is no longer
  glued to a pending `RenameFrom`.
- **`anvil` install dedup no longer reports `Failed` for a succeeded job** when
  a caller attaches its handle between the worker's terminal broadcast and its
  in-flight removal; the two now happen under one lock.
- **X11 clipboard reads of oversized non-INCR properties no longer truncate.**
  `read_property` returned the first chunk when a property exceeded the server's
  max request length; it now loops on `bytes_after` until the whole property is
  read.
- **Wayland: a paste racing a clipboard `set()` no longer misattributes the
  fd.** A `source.send` for a source destroyed by the set kept its fd in the
  queue, so the next paste wrote the payload to a stale pipe and every later
  paste shifted by one generation; the fd is now popped and closed.
- **macOS: a custom clipboard mime type containing a NUL returns an error**
  instead of panicking the calling thread.
- **Word text objects (`iw`/`aw`/`iW`/`aW`) keep blockwise visual mode.** In a
  `<C-v>` block they collapsed to charwise visual, so `<C-v>iw<` outdented the
  whole line instead of shifting the block. They now stay a block and extend
  their columns to the object end, matching nvim.

### Changed

- **`--headless` and `--embed` accept the host-owned `:set` tokens** (`mouse`,
  `endofline`, `explorer.open`) instead of rejecting them as unknown options.
  The TUI, headless and embed now share one `set_tokens` interception pass;
  mouse and explorer.open are no-ops without a terminal or explorer, and `:set`
  in those modes stays session-only (they load no config file) — a stated
  decision now, not an accident.

### Performance

- **Cut per-row allocations on the keystroke hot paths.** The substitute match
  collector no longer materializes a String per row across the range;
  paragraph/section/paren motions test emptiness via `line_bytes` and borrow
  rope chunks instead of a String + `Vec<char>` per row; `buf_line_chars` counts
  chars through the rope; `search_matches` returns its cached row borrow instead
  of cloning it per visible row per frame. Measured on a 100k-line buffer
  (release): substitute −19%, paragraph −29%, section −21%, paren −40%,
  buf_line_chars −18%, search_matches −28%.
- **Swap files no longer store the document twice for a single-node undo tree.**
  The undo section's root base is byte-identical to the streamed body in that
  case, so it is dropped on write and re-derived from the body on recovery.
  Measured on a 20k-line tree: undo section 1.22 MB → 26 bytes, whole file −1.22
  MB, serialization 292 µs → 1.5 µs.
- **LSP full-document sync no longer copies the buffer.** `didOpen` and full
  `didChange` serialize the shared `content_joined()` text straight from the
  `Arc` into the JSON-RPC frame instead of cloning it into a
  `serde_json::Value`. Wire bytes are unchanged. Measured on a 1.2 MB document:
  one full-document copy eliminated (−1.22 MB allocated).
- **`)` scans sentence boundaries row by row instead of materializing the whole
  buffer.** `sentence_step_forward` collected the buffer into a `Vec<Vec<char>>`
  and ran the flat scan on every keystroke — 31.5 ms per press on a 50k-line
  buffer with the next boundary three rows away. It now reuses the row-by-row
  forward scan and reads the tail from the rope: 31.5 ms → 2 µs with a nearby
  boundary.
- **Undo-cap pruning no longer pays an O(depth) path-membership scan per node.**
  `cap` tested `current_path().contains(..)` per node (O(N·depth) total); both
  prune helpers now read the maintained `UndoNode::on_path` flag directly.

## [0.41.0] - 2026-08-04

### Added

- **Code inside embedded languages folds now.** `foldmethod=expr` ran the fold
  query against the top-level tree only, so a ` ```rust ` block in a markdown
  file, or a `<script>` / `<style>` block in an HTML page, folded as one opaque
  unit. Each injected region is now parsed with its own grammar and folded with
  that language's query, with the rows offset into the host document. Measured
  against neovim 0.12.4: a markdown fixture with a ` ```rust ` and a ` ```bash `
  block went from 4 folds to neovim's 7, and an HTML page with a `<style>` and a
  `<script>` from 6 to neovim's 9 — exact matches in both cases. A region whose
  language has no bundled fold query, or whose grammar is not installed, is
  skipped silently; the host's own folds are never affected. Costs one extra
  query pass over the tree per reparse plus a parse of each region that changed
  (regions are memoised by content), measured at 9.0 ms → 10.0 ms for the whole
  fold pass on a 7334-line Rust file.

  Not covered: workflow-YAML `run: |` scripts, which neovim does fold. hjkl
  takes injection queries from the grammar's own repository and
  `tree-sitter-grammars/tree-sitter-yaml` ships none, so a YAML buffer has no
  injections at all — see `docs/backlog.md` §1.4b.

- **`hjkl_app::trash::TrashRoot` and `trash_path_in`** — the trash directory can
  now be named explicitly instead of always resolving `$XDG_CACHE_HOME`.
  `TrashRoot::Xdg` (the default) is the previous behaviour exactly;
  `TrashRoot::At(dir)` puts the trash where the caller says, created owner-only
  like the XDG one. `trash_dir()` and `trash_path()` are unchanged and now
  delegate to the `Xdg` variant. Mirrors `hjkl_anvil::store::AnvilPaths`.

### Fixed

- **Holding `<CR>` on an indented line no longer grows the buffer
  geometrically.** With `autoindent` (on by default), Enter inserted the copied
  indent in front of the text moving down instead of replacing that text's own
  leading whitespace, so pressing it at column 0 of an indented line doubled the
  indent every stroke — 8 spaces became 16, then 32. It also took the indent
  from the whole line rather than from the part left of the cursor, so the
  pushed-down row was re-indented with its own indent. A `handle_key` fuzz unit
  reached 394 MB and 20 s of CPU in 89 keystrokes; the same input now runs in 5
  ms, and the libFuzzer OOM artifact alongside it (2061 MB) in 15 ms. Behaviour
  is now neovim 0.12.4's, measured: `    foo` + `<CR>` at column 0 gives
  `['', 'foo']`, mid-line `    foo|bar` gives `['    foo', '    bar']`, and
  `noautoindent` moves the text down untouched.

- **A line ending in a separator other than `\n` no longer reads one character
  too wide.** ropey's `unicode_lines` splits on lone `\r`, U+000B, U+000C,
  U+0085 and U+2028 / U+2029 as well as `\n`, but the row helpers stripped a
  literal `'\n'` (`rope_line_str`) or subtracted a hard-coded one byte
  (`rope_line_bytes`) — so the separator stayed in the row's reported content,
  and `rope_line_bytes` kept two of U+2028's three bytes. A `j` onto such a row
  then clamped against the inflated length, landing the cursor past the row's
  last real character. All three helpers now derive the row's end from one rule,
  the same one `rope_row_range_str` already used; `\r\n` still keeps its `\r`,
  which is unchanged. Found by the `handle_key` fuzz target.

- **A C++ `try` block now folds over its whole statement.** Under
  `foldmethod=expr` the fold anchored on `try {` stopped at the `}` that opens
  the `catch`, and the catch clause itself was not foldable at all — the bundled
  C++ fold query captured neither node type, unlike hjkl's own Java, PHP, C#,
  JavaScript and TypeScript queries. Both now fold exactly as neovim does.

- **Grammars no longer fail to install when two hjkl instances warm a cold cache
  at once.** On a machine with no `~/.cache/bonsai` yet, two instances opening
  files of different types both cloned the shared nvim-treesitter / Helix query
  repository into the same staging directory, and whichever got there second
  deleted the first one's half-finished clone out from under its own `git`
  process. The affected buffer stayed plain text for the rest of the session,
  since a failed grammar load is never retried. Both grammar-source caches now
  take a cross-process lock on the destination and stage into a pid-private
  directory. Details in `crates/hjkl-bonsai/CHANGELOG.md`.

- **A visual selection can name its register.** `vll"ad`, `V"ay`, `"ap` over a
  selection — every one of them did nothing at all: the buffer was untouched and
  the selection stayed up, because the `"` register chord was wired into Normal
  mode only. In a selection the `"` fell through, the register letter armed the
  around-text-object chord, and the operator was eaten as that chord's target.
  Charwise, linewise and blockwise visual all take the selector now.

- **Blockwise `>` and `<` shift at the block's column, not the line's.** `<C-v>`
  selecting a rectangle from column 2 and pressing `>` indented the whole line —
  hjkl treated a blockwise shift as a linewise one. It now inserts (or removes)
  the shift where the block actually is, so `"abcdef"` becomes `"ab    cdef"`.
  Rows too short to reach the block's column are left alone, a tab that
  straddles the outdent boundary is split, and a row with no whitespace at that
  column is untouched.

- **A paste rejected for being too large says so.** `p` / `P` of a register past
  the 64 MiB budget did nothing and reported nothing — indistinguishable from
  pasting an empty register. It now shows vim's own
  `E342: Out of memory!  (allocating N bytes)`, where N is what was asked for.
  Pasting an genuinely empty register stays silent, as in vim.

- **`b`, `B`, `ge` and `gE` no longer walk through a line break.** A backward
  word motion from inside the first word of a line kept going onto the line
  above, because the step-back landed on the previous row's last character
  rather than on the virtual end-of-line cell vim puts there — a real character
  of the same class, so the word run never ended. `b` on `"abc def\nghi jkl"`
  from `(1, 1)` went to `(0, 4)` where vim stays on `(1, 0)`. `ge` inside the
  buffer's very first word did not move at all. Measured over every legal cursor
  position in 8 buffers against neovim 0.12.4: 221 disagreements before, none
  after.

- **`d}` on the last character of a buffer deletes it.** vim's `}` counts as a
  successful inclusive motion even when it has nowhere left to go, so `d}` with
  the cursor already on the final character removes that character; hjkl read
  the zero-distance motion as a failure and did nothing. `y}` and `c}` in the
  same position were equally inert. `d$` on an empty line is still a no-op — it
  is the other zero-width range of that shape and is now pinned by a case of its
  own.

- **`2C` and `2D` no longer wipe a one-line buffer.** A counted `$` clamped to
  the last line where vim fails on it, so `2$` on a single-line file moved to
  end-of-line and `2C` / `2D` there emptied the line — vim does nothing at all,
  because `c2$` needs a line below to extend onto. An overshooting count is
  still not a failure: `5$` on three rows lands on the last one and `5C`
  collapses all three, exactly as before. Found by the differential fuzzer,
  whose divergence count at seed 777 drops from 84 to 83.

- **Every `.`-repeatable change now repeats into the register it named.** The
  last three that did not were `x` / `X`, a visual-mode operator, and `dgn` /
  `cgn`: each fell back to the unnamed register on the repeat, leaving `"a`
  holding the FIRST change's text. `"axl.` on `"abcdef"` left `"a` as `a` where
  neovim leaves `c`, and the same shape held for `vll"adw.` and `"acgn`. In
  every case the live keystroke already honoured the register — only the repeat
  dropped it.

- **`}`, `{`, `b`/`B` and `(`/`)` landings match neovim.** Four vim-parity
  defects found by the differential oracle, all verified case by case against
  neovim 0.12.4 and now pinned in `crates/hjkl-compat-oracle/corpus` (812 → 832
  cases):
  - `}` in the last paragraph stopped at column 0 of the last line; it now
    reaches that line's last character (`}` on `"    it's.{foo}.A-B.{A-B}"` goes
    to column 23, not 0), and `d}` there deletes through end-of-buffer.
  - `}` and `{` stepped over a blank line that was directly next to the cursor,
    skipping a whole paragraph — `}` on `"abc\n\ndef"` from row 0 landed on row
    2 instead of row 1. A counted `9}` clamped to the last row instead of
    failing, and a buffer ending in `\n` parked the cursor on a phantom row that
    vim does not have.
  - `b` / `B` did not move at all when the previous word start was an empty or
    whitespace-only line (`B` at column 0 of row 1 above a blank row 0).
  - `2)` on a line with no sentence terminator moved to end-of-line instead of
    failing; vim's `(`/`)` are all-or-nothing under a count. This was the cause
    of the composite `V}u2)1gUiW` divergence, which now produces neovim's
    `"'QUX'  a-b"`.

- **`foldlevelstart` is a level again, not a boolean.** The auto-fold pass
  computed `default_closed = foldlevelstart == 0` and handed that one flag to
  `View::set_auto_folds`, so only `0` (everything closed) and "anything else"
  (everything open) existed. `:set foldlevelstart=1` — vim's "open the top
  level, close what is nested inside it" — opened the whole file. Every value
  now behaves as vim's: a fold whose 1-based nesting level exceeds
  `foldlevelstart` starts closed. Levels are derived from the fold ranges'
  containment, so `foldmethod=expr` and `foldmethod=marker` both get them with
  no query change. Unchanged: `0` still closes everything, the default `99`
  still opens everything, a fold the user opened is never re-closed by a
  reparse, and `zf` folds neither take a level nor give one. Measured against
  neovim 0.12.4 (`foldclosed` / `foldclosedend` at `foldlevelstart` 0/1/2/3/99).
  Note that vim's own default for the option is `-1` ("leave `'foldlevel'`
  alone", which ends up closing everything); hjkl's is a `u32` defaulting to
  `99`, so that mode does not exist here.

- **C# files now auto-fold.** `.cs` buffers produced no tree-sitter folds at
  all. `bonsai.toml` declares the same C# grammar twice — `[language.c-sharp]`
  (helix queries) and `[language.c_sharp]` (nvim-treesitter queries) — and
  `GrammarRegistry` resolves an extension to the alphabetically first entry, so
  a `.cs` buffer loads under the name `c-sharp`, while
  `hjkl_bonsai::folds::builtin_folds` was keyed only on `c_sharp` and returned
  `None`. The bundled `folds/c_sharp.scm` had never run against a real buffer.
  Measured on a 77-line C# fixture: 0 folds before, 20 after, covering every
  fold neovim produces for the same file.

- **The cursor no longer drifts left of its own glyph on lines containing CJK,
  emoji or combining marks.** `hjkl_buffer::char_col_to_visual_col` and
  `visual_col_to_char_col` counted every non-tab character as one screen cell,
  while the renderer's `paint_row` advanced by the real `unicode-width` value.
  Both now measure with the renderer's rule: `unicode-width` per char, tab stops
  computed from the visual column (so a wide char before a tab shifts the stop),
  and zero-width chars (combining marks, `U+FE0F`) folded into the preceding
  cell instead of counted as one.

  Everything keyed off those two helpers moves with them: `j` / `k` sticky
  column (`Move::Vertical`, `apply_sticky_col`, `motions::move_vertical`), `$`,
  mouse click and drag translation, the terminal cursor block
  (`Editor::cursor_screen_pos` — its private `visual_col_for_char` was a second
  naive copy and now delegates), and the `cursorcolumn` bar. Landing inside a
  double-width glyph's second cell now snaps to that glyph, as in vim; a
  combining mark is never a cursor landing site. Expectations were taken from
  neovim 0.12.4 (`virtcol()`, `getcurpos()`, `j` between wide and narrow lines).

  Two divergences from vim are deliberate and documented on `hjkl_buffer::geom`:
  control characters measure one cell (matching `sanitize_control`'s
  single-width Control Pictures substitution, not vim's two-cell `^X`), and
  emoji presentation sequences such as `U+2764 U+FE0F` measure one cell rather
  than vim's two, because widths are resolved per `char` on both sides rather
  than per grapheme cluster.

- **The explorer's `dd` / `u` / `<C-r>` tests no longer fail at random under a
  parallel `cargo test`.** They overrode `XDG_CACHE_HOME` to isolate the trash,
  and an environment variable is process-global: while the override was in
  effect, any _other_ test thread constructing an `App` resolved
  `$XDG_CACHE_HOME/hjkl/swap` and so created an `hjkl/` directory inside the
  overriding test's own explorer root. Directories sort first, so `j` then `dd`
  deleted that stray row instead of the file under test and the victim survived
  ("dd must trash"). The explorer pane now carries a `TrashRoot`, tests inject a
  `TempDir` outside the explored tree, and no explorer test touches the
  environment. Measured on the `hjkl` binary: 5 failures in 30 full runs before,
  0 in 30 after.

- **A markdown code block's background now covers whole lines, and the
  cursorline shows through it.** The tint stopped at each line's last character,
  so a block rendered as a ragged shape with untinted gaps on its blank lines;
  and on the cursor row the block's background painted over the `cursorline`
  highlight, hiding where the cursor was.

  Span tables (`hjkl_syntax::build_by_row`, `hjkl_picker::preview`) now mark a
  multi-row span that covers a row's end by recording one byte past that row's
  content, `Editor::install_syntax_spans` keeps that byte instead of clamping it
  away, and `BufferView::paint_row` fills the rest of such a row with the span's
  bg. Spans confined to a single row are untouched, so a hex-colour swatch or
  TODO marker never bleeds across the row.

  On the cursor row the block's colour is dropped from every span carrying it,
  so the whole row reads as `cursorline` — including a fence line, whose
  backticks markdown captures as `@markup.raw.block` a second time in a narrow
  span of their own. A bg span in any other colour still paints there, so a
  hex-colour swatch does not vanish when the cursor lands on its line.

- **Enter discarded the selected argument in the command bar.** With the popup
  open on an argument — `:set foldmet`, `:colorscheme drac`, `:e src/ma` — Enter
  ran the half-typed line instead of accepting the highlighted candidate. The
  "line is already runnable → execute" rule exists so `:w<Enter>` writes rather
  than accepting `wall`, but an argument's leading word (`set`, `colorscheme`,
  `e`) is runnable by construction, so the rule fired for every argument popup
  unless the user had first moved off the first item. It is now limited to
  command-name completion; argument completion accepts on Enter and runs on the
  second, as it always did for command names.

- **Auto-folds in markdown covered the wrong lines.** Every fold ran past its
  own content: a `## Section` fold hid the NEXT section's heading, a fenced code
  block swallowed the blank line under it, a list ate the heading below it, and
  the last section of a file folded one row past the end of the buffer. Fold
  ranges now drop a trailing row the node only touches at column 0 (which is
  where markdown's block nodes end), and honour `(#trim! @fold)` so a section
  stops at its last line of text. Verified against neovim's
  `vim.treesitter.foldexpr` on this repo's own markdown: every fold hjkl
  produces is now byte-for-byte one of neovim's.

- **YAML folds were anchored on the wrong line.** The query captured
  `(block_mapping)` / `(block_sequence)`, and a container starts at its first
  child — so `key:` never got a fold of its own, and the document-level mapping
  produced one fold starting at the first key that swallowed every sibling below
  it. Now captures `(block_mapping_pair)` / `(block_sequence_item)`, as neovim
  does.

- **A `zf` fold was silently taken over by the auto-fold engine** when a
  tree-sitter fold started on the same row: the manual fold's extent was
  replaced and the fold became auto-generated, so the next reparse wiped it.
  Manual folds now keep their row.

- **`:set foldmethod=manual` left the previous method's folds on screen.** The
  `manual` arm did nothing at all, so marker/expr folds stayed and `zR` / `zM`
  still operated on them. Vim recomputes when foldmethod changes; `manual` now
  starts from none, keeping only `zf` folds.

- **Deleting the last foldable construct left its fold behind** under
  `foldmethod=expr` — the auto-fold pass skipped the update whenever the new
  range list was empty.

- **`:set nofoldenable` did nothing.** The render path read the fold list
  directly, so folded lines stayed hidden and the only way out of a fold was to
  open it. Folded lines now show while each fold keeps its closed state, so
  `:set foldenable` restores exactly what was folded.

### Performance

- **The auto-fold pass ran every frame instead of once per edit.**
  `set_auto_folds` bumped the buffer's generations even when the fold set was
  unchanged, so the caller's "recompute once per edit" guard never matched — and
  because `dirty_gen` also keys the syntax render cache, every frame paid a full
  fold extraction (`rope.to_string()` + a whole-tree query) AND a viewport
  re-highlight. An unchanged fold set is now a no-op.

### Fixed

- **The theme's background now covers the whole TUI, not just the editor pane.**
  Anything that painted no background of its own fell through to the terminal's
  colour and read as a hole in the theme: the splash screen (every cell of it),
  the gaps beside the tabline and the explorer rows, the split separator column,
  and toast popups, which clear their rect before drawing. The frame now starts
  with a base fill in `theme.ui.background`, the separator repaints it under its
  glyph, and toasts take `theme.ui.panel_bg` like the other floating panels.
  `theme.transparent` is unchanged — it still shows the terminal through.

- **The start screen now retires itself and takes the whole terminal.** It
  stayed up until the first keypress, and the status line was drawn underneath
  it — so a fresh `hjkl` with no file showed art with a `START` badge stuck to
  the bottom. It now covers the full frame (nothing else drawn) and disappears
  on its own after two seconds; any keypress still dismisses it immediately. The
  event loop shortens its poll wait while the splash is up, so it animates
  smoothly and expires on time instead of at the next idle wake.

### Added

- **`:colorscheme` previews live.** Moving through the completion popup with the
  arrow keys, `<C-n>`/`<C-p>` or `<Tab>` applies each scheme as it is
  highlighted — UI chrome and syntax both — so a theme is judged on the actual
  buffer instead of by its name. Leaving the prompt any way that does not run
  the command (`<Esc>`, `<C-c>`, backspacing it away, `<C-f>`, or running some
  other command) puts the previous scheme back; running `:colorscheme <name>`
  keeps it. Preview happens on navigation only, so typing does not churn the
  theme on every keystroke.

### Changed

- **The matchparen partner no longer looks like a second cursor.** Both the
  cursor's bracket and its match were painted `BOLD | REVERSED`, and reversed
  video is what draws the cursor cell — so `%` jumped between two identical
  blocks with nothing saying which one you were on. The cursor cell now keeps
  its block and the partner gets the muted `match_paren` bg with its own
  foreground, one step quieter. Same rule for the open/close tag-name highlight.

## [0.40.0] - 2026-08-01

### Fixed

- **`cursorcolumn` painted column 0 instead of the cursor's column.** The
  renderer read the cursor off `BufferView.buffer`, which is the SHARED slot
  buffer — a different object from the focused window's editor, and one that
  nothing moves. `cursorline` had been given an explicit row override for
  exactly this reason; the column never got the matching one, so the bar sat at
  column 0 for the whole session.

  The same pass also used the cursor's CHAR column directly as a screen offset,
  so even with the right column a line containing a tab would have put the bar
  one cell left per tab. It now goes through `char_col_to_visual_col`, the same
  expansion `paint_row` applies.

### Added

- **`:set inv<name>` now works.** Completion has always offered it, and the
  parser has always rejected it — for every one of the 34 boolean options.
- **`:set <name>!` now works for every boolean.** It was implemented for
  `number`, `relativenumber`, `cursorline` and `cursorcolumn`, and reported
  "unknown :set option" for the other thirty.
- **`:set modifiable?`, `:set linebreak?` and `:set motion_sneak?` answer.** All
  three were settable but had no query arm.
- `:set mouse` is now offered by completion (it was always applyable).

- **`:set` now writes itself back to your config file.** Any global option set
  from the command bar is persisted to `~/.config/hjkl/config.toml` immediately,
  so a session and the file never disagree. The write is format-preserving —
  comments, key order and quoting all survive — and is the value the editor
  actually ended up with, so clamped numbers and `formatoptions+=` land already
  folded in. A `:set` the editor rejected writes nothing, and a query
  (`:set nu?`) writes nothing.

  Buffer-local options are session-scoped and never written: `filetype`,
  `commentstring`, `readonly`, `modifiable`, `endofline`. Each describes one
  buffer rather than a preference.

  This is a deliberate divergence from vim, where `:set` is transient and the
  config file is the only persistence mechanism.

- **Every global `:set` option now has a config key.** A new `[options]` table
  in `config.toml` carries all of them, named exactly as `:set` names them, so
  `:set scrolloff=8` and `options.scrolloff = 8` are the same knob.
  `.editorconfig` and a file's own modeline still layer on top, in that order.
- `:set hlsearch`, `:set incsearch`, `:set modeline` and `:set modelines`. All
  four existed as config keys but had no `Settings` storage, so nothing could
  move them at runtime. They are real settings now and are wired to behaviour:
  `nohlsearch` suppresses the highlight while leaving the pattern armed (so
  `n`/`N` still work — that is what separates it from the `:nohlsearch`
  command), `noincsearch` stops the search prompt previewing before submit, and
  `modeline`/`modelines` apply to files opened after they change, matching vim's
  global-option semantics over hjkl's per-slot settings.
- `:set updatetime`, `:set colorizer` and `:set colorizer_filetypes` — three
  engine settings that had no `:set` name, so they could only be reached
  programmatically. `colorizer_filetypes` takes a comma-separated list.
- `modifiable` / `ma` and `motion_sneak` / `snk` are now offered by `:set`
  completion. Both were settable already; neither appeared in the candidate
  list.

### Changed

- **`editor.tab_width` and `editor.expandtab` are gone**, superseded by
  `options.tabstop`, `options.shiftwidth`, `options.softtabstop` and
  `options.expandtab`. `tab_width` set all three widths at once; the new keys
  let them differ. A config still carrying the old keys fails to load with the
  offending key named — pre-1.0, no shim.

### Changed

- **`:set` is now driven by one option table.** An option's name used to appear
  in ~20 hand-maintained places — seven tables inside the `:set` implementation
  alone — and nothing kept them in step. The three bugs above are each a table
  that was updated while its siblings were not. `hjkl_engine::options_registry`
  now holds one entry per option (name, aliases, kind, scope, getter, setter);
  the completion lists, the enum candidates, the `?` query, and the `name` /
  `noname` / `invname` / `name!` / `name=value` forms are all derived from it,
  as are the host's config-key mapping and TOML writer. The `no…` / `inv…` / `!`
  forms are properties of the option's kind rather than four independent lists,
  so they can no longer disagree. Net −867 lines in the `:set` module.
- **The cursor-highlight defaults are swapped: `cursorline` is now on and
  `cursorcolumn` off.** A fresh session highlights the cursor's row and leaves
  its column alone. `cursorline` is the deliberate divergence from vim now
  (`:set nocul` turns it off); `cursorcolumn` returns to vim's `nocursorcolumn`,
  reverting the 0.39.1 change below. Both `Settings::default` and
  `Options::default` moved together — they are the pair a regression test pins,
  because when they disagreed the `Options` side silently won for every shipped
  session.

## [0.39.1] - 2026-07-31

### Changed

- **`cursorcolumn` is now on by default.** The column under the cursor is
  highlighted in a fresh session; `:set nocuc` turns it off. This is a
  deliberate divergence from vim, which defaults to `nocursorcolumn` — unlike
  `cursorline`, which was aligned _to_ vim's default in 0.38.0.
- **`postcard` is now built without default features**, dropping the `heapless`
  0.7 → `atomic-polyfill` chain. `atomic-polyfill` is unmaintained with no safe
  upgrade (RUSTSEC-2023-0089) and was reaching released `hjkl` artifacts.
  Nothing used postcard's heapless types.

### Fixed

- **The quickfix and command-line-window buffers no longer get swap files or
  block `:qa`.** Both guards tested `is_explorer()`, so only the file explorer
  was recognised as a programmatic scratch buffer. A `:copen` listing or a `q:`
  history buffer was persisted to a swap file — after a crash the editor would
  offer to "recover" a quickfix listing — and a dirty one made `:qa` refuse with
  an `E37` naming `[No Name]`, which no `:w` could satisfy. Both now test
  `is_special()`, which covers explorer, quickfix and cmdline alike.
- **Visual-mode `<C-a>` / `<C-x>` now handle hexadecimal literals.** The scan
  looked for `is_ascii_digit()` only, so the `0` of `0xFF` matched as a decimal
  and `Vg<C-a>` produced `1xFF`. Hex literals are now recognised ahead of bare
  decimals, incremented in hex with their digit width preserved.
- **`<C-a>` / `<C-x>` now preserve hexadecimal digit case, and visual mode
  preserves decimal zero-padding.** Hex results were always lowercased, so
  `0xAB` became `0xac`; vim takes the case from the last letter digit of the
  original (`0xaB` → `0xAC`, `0xAb` → `0xac`) and falls back to the case of the
  `x` / `X` prefix when the number has no letter digit (`0X19` → `0X1A`).
  Separately, visual `<C-a>` dropped the zero-padding that normal mode had
  always kept, turning `007` into `8` rather than `008`. Normal and visual mode
  now share one implementation, so the two cannot drift apart again. Expected
  values checked against nvim 0.12.4.
- **A soft-wrapped viewport now scrolls back into range when another window
  shrinks the buffer.** When the cursor's screen row could not be computed,
  `ensure_cursor_visible` zeroed `top_col` but left `top_row` past the end of
  the shortened rope, so the window rendered empty until the next event moved
  the cursor. `top_row` is now clamped to the cursor's row against the live
  rope. The same path also covers a cursor sitting inside a closed fold.
- **Cursor movement, substitutions, indentation, and case conversion now retain
  their intended semantics.** Vertical cursor moves convert display-column
  curswant back to character columns on tabbed lines; inline `\c` / `\C`
  override substitute `/i` / `/I` flags in both execution and confirm-match
  collection; outdent consumes tab width; Unicode case mappings retain every
  output character; empty `R<Esc>` dot-repeat is a no-op; and modified `.` keys
  do not trigger repeat.

- **`<C-e>` / `<C-y>` now keep the cursor on the line and honour the sticky
  column.** When the scroll pushed the cursor onto a new row it re-used the old
  column verbatim, so on a shorter row the cursor parked past end-of-line, and
  the column `j`/`k` aim at was ignored rather than preserved. They are
  screen-line vertical motions and now follow the same rule as `j`/`k`: clamp to
  the row, keep the un-clamped want, so scrolling across a short line and back
  returns to the original column.
- **`=` over a line starting with an unmatched `}`, `)` or `]` no longer
  crashes.** The running bracket depth is an `i32`, and `saturating_sub`
  saturates towards `i32::MIN` rather than clamping at zero, so a close bracket
  at depth 0 produced `-1`; the `as usize` cast wrapped it to `usize::MAX` and
  building the indent string panicked with "capacity overflow". A plain `==` on
  a `}` line was enough to take the editor down. Found by the `handle_key` fuzz
  target.
- **Editing a buffer containing U+0085, U+2028 or U+2029 no longer panics.**
  ropey treats those as line breaks alongside `\n`, but they are 2–3 bytes wide,
  and `rope_row_range_str` stepped back a hard-coded single byte from the next
  row's start to drop the separator. That landed inside the separator, and
  `Rope::byte_slice` panics on a non-char-boundary index — taking the editor
  down on comment toggling (`gcc`), linewise `:` ranges and `!` filters. Found
  by the `handle_key` fuzz target.
- **`/pattern<CR>` now sets the sticky column, so the next `j`/`k` keeps the
  match's column.** A search hit is an explicit jump, and vim resets `curswant`
  on those — hjkl left the value from whatever vertical motion came before, so
  `j` after a search snapped the cursor back to the old column. `n`/`N` were
  already correct; the `/` and `?` prompt commits, and the `+/pattern` startup
  search, were not.
- **Linewise case operators (`guu`, `gUU`, `g~~`, `g??`) no longer scramble the
  buffer when the range includes the last line.** The transform text was
  re-inserted at the post-cut cursor position, which clamps to a wrong location
  when the cut removes the trailing newline. It now inserts at the range start,
  using a separate read-before-cut so the text format is consistent regardless
  of position. Found by the differential fuzzer against nvim.
- **Linewise visual case (`VgU`, `Vgu`, `Vg~`, `Vg?`) now lands the cursor at
  column 0 of the selection start** (matching vim) instead of at the first
  non-blank character of that line. Found by the differential fuzzer.
- **Operators (`dk`, `d+`, `d-`, `y+`, etc.) at buffer edges now abort when
  their motion makes no progress**, matching vim. Previously the motion silently
  clamped to the edge and the operator edited anyway — `dk` on a one-line buffer
  deleted the line. `+`, `-`, and `_` at the first or last line are now also
  no-ops instead of clamping. Found by the differential fuzzer.
- **`[count]$` now moves `count-1` lines down before landing at line-end.**
  Previously the count was discarded, so `2$` behaved as `$`. The count also
  threads through `D` and `C`: `[count]D` and `[count]C` now extend through
  `count-1` additional lines (equivalent to `d[count]$` / `c[count]$`). Found by
  the differential fuzzer.
- **Blockwise visual mode (`<C-v>`) with a text object (`iw`, `ip`, etc.) now
  switches to charwise or linewise visual and selects the text object's natural
  range**, matching vim. Previously the block anchor was kept, the `i` / `a` key
  was treated as an unknown motion, and the `w`/`p` that followed extended the
  block — producing a wrong column range. Found by the differential fuzzer.
- **`j` / `k` now track the virtual (display) column, not the raw character
  index.** When crossing lines with different tab structures the cursor now
  lands at the same display column instead of the same char index, matching vim.
  All vertical motions (`j`, `k`, `<C-e>`, `<C-y>`, `gk`, `gj`) and
  sticky-column write sites were updated to convert between char and display
  columns using the current `tabstop`. Found by the differential fuzzer.
- **Assorted motion landing edge cases fixed.** `}` at EOF no longer jumps to
  row 0. `b` inside leading whitespace lands at column 0 (the previous "word").
  `B` at column 0 of a non-first line finds the previous BIGWORD instead of
  jumping to row 0. Counted `w` / `W` past EOF no longer parks past the last
  character. `W` on consecutive blank lines no longer advances to out-of-bounds
  rows. Found by the differential fuzzer.
- **Linewise register content now always ends with `\n`**, matching vim's
  convention. `dd` on an empty buffer now records `"\n"` instead of an empty
  string; `3dd` at EOF records `"l2\nl3\n"` instead of `"\nl2\nl3"` (newline on
  the wrong end); `dip` on a single line appends `\n`. Found by the differential
  fuzzer.
- **Paste operations with huge count prefixes (`yy1000000p`) no longer OOM.**
  The paste count is now capped at `MAX_COUNT` (999,999,999), the same bound
  already applied to motion walks. Without the cap a large count could allocate
  gigabytes. Found by the `handle_key` fuzz target.

## [0.39.0] - 2026-07-27

### Changed

- **The explorer and the quickfix/location-list panel are now per-tab**, as in
  vim. Opening the explorer in one tab no longer opens it in every tab, and each
  tab has its own quickfix window onto the (still shared) quickfix list.
- **`explorer.width` and `panel.height` now mean the rendered size.** They
  previously included the separator, so `explorer.width = 30` drew a 29-column
  explorer; it now draws 30. Both grow by one cell at the same setting.
- **`:b N` targeting a quickfix or command-line-window buffer reports `E86`**
  instead of silently doing nothing. Those buffers were never switchable — the
  validation just failed to say so, as it already did for the explorer.

### Fixed

- **`:e <file>` from the quickfix panel no longer loads the file into the
  panel.** The file opened inside the dock window while the editor still tracked
  that window as a dock, so closing the panel afterwards disposed of the window
  and slot holding the user's file. The open path now always targets a regular
  window, and no special scratch buffer can be mistaken for a discardable empty
  placeholder.
- **The command-line window (`q:`) no longer leaks its buffer** when an earlier
  buffer was closed while it was open. It removed its slot by an index recorded
  at open time, which drifts when another slot is removed first; the same stale
  index also made the history buffer start counting as a real buffer, so it
  appeared in `:ls` and the buffer line and turned `H`/`L` into buffer cycling.
- **Popups no longer misjudge the screen height by a row** on the first frame
  when a quickfix or command-line window is open — the screen-rect helper
  counted those scratch buffers as real ones and assumed a tab bar the renderer
  had not drawn.

- **`<C-h>` now leaves the quickfix / location-list dock.** The bottom dock is
  carved out of the area remaining after the explorer, so the explorer really is
  its left neighbour — but the adjacency check required tree membership, which
  no dock has, making an open `:copen` list a one-way trip: `<C-k>` back up to
  the tree was the only exit. Under `$TMUX` it was worse than a no-op, falling
  through to `tmux select-pane -L` and moving focus out of the editor.

- **`:q` no longer needs pressing twice when the explorer or a quickfix dock is
  open.** Dock slots live in the buffer list like any other buffer, so `:q` on
  the last real file took the multi-buffer `:bdelete` path instead of quitting —
  deleting the file's buffer and leaving the dock's scratch buffer rendering in
  the main window.
- **`:q` inside the explorer or a `:copen` / `:lopen` dock now closes that
  dock** instead of deleting its scratch buffer, matching `:close` / `<C-w>c`
  and vim's behaviour in a quickfix window.
- **`<C-w>q` inside a dock no longer quits the editor.** Docks are not layout
  leaves, so the "last window" check was true while a dock was focused and took
  the quit branch.
- **`H` / `L` keep their viewport-motion fallback when a dock is open.** With a
  single real buffer plus an open explorer they cycled buffers (a no-op, since
  cycling skips dock slots) instead of jumping to the top/bottom of the
  viewport.

## [0.38.0] - 2026-07-27

### Changed

- **`cursorline` now defaults to off, matching vim.** The `Settings` and
  `Options` defaults contradicted each other (`false` vs `true`) and the `true`
  side won in a fresh TUI session. Vim's default is `nocursorline`; use
  `:set cursorline` to re-enable the highlight.

### Added

- **`'endofline'` and `'fixendofline'` options, matching vim.** `:set eol` /
  `:set noeol` / `:set eol?` and `:set fixeol` / `:set nofixeol` /
  `:set fixeol?` are supported. `endofline` is per-buffer and derived from the
  bytes the file was loaded with; `fixendofline` defaults on, which is the
  behaviour hjkl already had (a file with no final newline gains one on save).

### Fixed

- **`:w` no longer truncates a file containing exactly one newline.** The
  trailing newline was derived from buffer content alone, and a 0-byte file and
  a 1-byte `"\n"` file both produce an empty body — so the latter was written
  back as 0 bytes. Save now honours the loaded file's own end-of-line state.
  Verified byte-for-byte against nvim across the full matrix of empty, `"\n"`,
  no-final-newline, final-newline and trailing-blank-line files under both
  `fixeol` and `nofixeol`.
- **Installing syntax spans for a stale row count no longer panics.**
  `Query::line_bytes` documents that out-of-range rows return 0, but the rope
  backend forwarded straight to `ropey::Rope::line`, which panics past the last
  line — reachable when a lagging syntax worker delivers a span table with more
  rows than the buffer still has.
- **`current_options()` no longer resets unmapped options to defaults.**
  `Settings::to_options` mapped only ~20 of 50 fields and backfilled the rest
  with `Options::default()`, so any read-modify-apply through the nvim API
  silently reset `number`, `signcolumn`, fold options and two dozen others. The
  conversion is now exhaustive-by-construction with a round-trip identity test,
  and `apply_options` gained the 7 fields it was missing in the other direction.
  Two app-side option-wipe paths (config overlay, `-R` readonly flag) were fixed
  along the way.

### Performance

- **`:earlier` / held `g-` no longer stall on deep histories.** Undo nodes at
  every 16th depth pin their materialized rope as a replay anchor, and
  materialization now caches every intermediate it replays instead of only the
  target, so a step-by-step walk pays one replay per interval rather than one
  per step. A full tip→root walk on a 1024-deep history drops from 212 ms to
  5.45 ms; a single cold jump from 445 µs to 75 µs. Keyframes are a pure
  in-memory cache — nothing changes in the undofile format.
- **LSP notifications move the document text instead of copying it.** `json!`
  deep-copies even an owned value, so every didOpen/didChange copied the whole
  buffer once more on its way into the params object. On a 2 MiB document:
  didOpen 27.6 µs → 110 ns, incremental didChange 49.8 µs → 396 ns. Wire bytes
  unchanged.
- **Syntax-span installs stopped cloning each row to measure it.** The clamp
  read the row length via `buf_line(row).len()` — a full line clone allocated
  under the content mutex — and now reads it from the rope directly. The
  per-keystroke viewport patch drops ~16%.
- **LSP sends no longer deep-clone the params tree.** The JSON-RPC envelope
  moves `params` in instead of re-serializing it via `json!` (~40 µs → ~0.3 µs
  per full-document sync at 3.5 MB; O(1) instead of O(tree)). Wire bytes
  unchanged.
- **Undo records cost O(edit) instead of O(document).** The undo diff walks the
  two ropes' chunks (with a shared-leaf pointer fast path) instead of
  materializing both as strings: 1.55 ms → ~30 µs per edit keystroke on a 3.2 MB
  file, with a differential test suite pinning exact equality against the old
  implementation.
- **Statusline search count is O(log matches) per frame.** Match offsets are
  cached against `(buffer, generation, pattern)` and the cursor index derived by
  binary search; previously every cursor move rescanned the whole document
  (0.3–1 ms/frame on large files, now ~0.6 µs).
- **Picker preview no longer reparses every frame.** The preview's tree-sitter
  parse and span table are cached against a preview generation token;
  steady-state frames drop from 5–42 ms (100 KB–1 MB files) to ~2–17 µs, and the
  per-frame content copy is gone.

- **Diff mode classifies only the visible rows.** The per-frame row
  classification is clipped to the viewport with one rope snapshot per side (7.3
  ms → 41 µs per frame on a 10k-line/2k-change diff); `]c`/`[c` navigation is
  unaffected.
- **Fold queries stop cloning the fold list.** New borrow-style
  `with_folds`/`has_folds`/`fold_gen` accessors; `j`/`k` over folded regions
  take one lock per motion instead of one lock+clone per row (−55%), and the
  per-keystroke window fold sync is gated on a fold generation counter.
- **Explorer render data is cached against the buffer generation.** Text, line
  offsets, conceals and overlay nodes rebuild only on reconcile (steady-state
  derivation 144 µs → 0.3 µs on a 2000-entry tree); git-status coloring stays
  per-frame.
- The viewport span table is no longer triple-copied per recompute, and ~100
  small per-event allocations were removed across the render and edit paths
  (undo mark snapshots via `Arc`, insert-mode lookback without line
  materialization, gutter/wrap scratch reuse, warm-only search cache priming).

- **New languages now attach LSP servers.** The LSP language-id lookup routes
  through the hjkl-lang registry (with a 3-entry override for `tsx`/`jsx`/`.h`)
  instead of a frozen 11-extension inline map, so any manifest-known language
  resolves — comment leads and filetype labels gain the same coverage.
- **Indexed terminal colors resolve through the real xterm-256 palette
  everywhere.** `hjkl-engine-tui` flattened `Indexed` to black while
  `hjkl-editor-tui` used the true palette; the conversion now exists once
  (engine-tui) with a cross-crate consistency test.

### Internal

- **Workspace-level clippy lints** now enforce `or_fun_call` and
  `unused_peekable` (deny) plus `assigning_clones`, `format_push_string`,
  `manual_let_else`, `map_unwrap_or`, `use_self` and `redundant_pub_crate`
  (warn) via `[workspace.lints]` across all 60 members; ~1200 sites were
  modernized to match (let-else, `map_or`, `Self`, true visibility). A real bug
  fell out: `:later <time>` sampled two different clock instants on overflow.
- Read-only `Vec`/`String`/`Value` parameters became borrows on the edit,
  restore, LSP and grammar-load paths (removes a per-keystroke allocation in
  insert mode), 20 verified-redundant clones were dropped (one on the
  `smartindent` keypress path), and mode-invariant `unwrap()`s in the
  cmdline/diff/swap paths now name their invariant in an `expect`.

- Deduplicated drift-prone copies found by the round-2 audit: the visual-exit
  `'<`/`'>` mark computation (two byte-identical 35-line copies in the vim FSM),
  the `InsertSession` setup tail (`begin_insert`/`begin_insert_noundo`), and the
  `:map` listing's private copy of `encode_macro`.

## [0.37.1] - 2026-07-26

### Fixed

- **Macro recorder dropped every literal `q` key.** Insert-mode text containing
  `q` (e.g. `iquick<Esc>`) recorded as `iuick<Esc>`, and `f`/`t` pending targets
  of `q` recorded as just the operator (desynchronising the whole macro on
  replay). The redundant `q`-exclusion clause in the recorder hook is removed.
- **TUI `:w` silently deleted a file's trailing blank line.** The save path
  decided whether a trailing newline was needed by checking
  `body.ends_with(b"\n")`, but the load path strips exactly one trailing newline
  — so `"x\n\n"` loaded as `"x\n"` and `:w` wrote the shorter form on a no-edit
  save. The condition now matches headless/embed: `!body.is_empty()`.
- **`copy_dir_atomic` failed on trees containing a read-only file.**
  `std::fs::copy` stamps the source's mode onto the destination immediately,
  rendering it read-only, and the subsequent `write(true).open(…)` for fsync
  returned EACCES. The file-copy arm now copies bytes through owned read/write
  handles, flushes while the destination is still writable, and only then
  applies the final mode.
- **Linewise-delete inverses did not round-trip for last-row and whole-buffer
  deletes.** The recorded inverse `Edit` used a trailing-newline form aimed at a
  row that no longer existed (last-row case) or fabricated an extra trailing
  newline (whole-buffer case), violating the `apply_edit` inverse contract. Both
  branches now record the exact removed span; the round-trip property test
  covers `MotionKind::Line` including whole-buffer deletes.
- **`:map`/`:nmap` key notation is now parsed by the hjkl-keymap chord parser.**
  `<Enter>`/`<Return>`/`<Escape>`/`<Insert>`/`<F5>`/`<S-Tab>` no longer bind as
  literal `<`,`E`,`n`,… character sequences, and `<C-S-x>` no longer silently
  binds Ctrl+S. SHIFT is preserved on non-letter keys, so `:nmap <S-Tab> x`
  matches Shift-Tab instead of hijacking plain Tab. Invalid or unrepresentable
  notation now reports an `E:` error instead of silently falling through to "not
  an editor command".
- **RPC filesystem confinement now resolves symlinks.** In `--embed` /
  `--nvim-api` mode, `:w`, `:e` and `:r` paths are resolved through
  `hjkl_fs::resolve_under`, so a lexically-innocent relative path traversing a
  symlink out of the working directory is refused instead of followed.
- **`J`/`:join` implements vim's no-space exceptions.** No space is inserted
  when the first line ends in whitespace or the next line starts with `)`;
  covered by new nvim-oracle corpus cases.
- **`viewport_math` regained the stale-cursor clamp** its relocation from
  hjkl-buffer dropped: a per-window cursor left beyond EOF by another view
  shrinking the shared buffer no longer walks `Query::line` out of bounds.
- **X11 clipboard no longer accumulates mime formats across copies.** Each
  `set()` replaces the owned formats wholesale, matching the Wayland, Windows
  and macOS backends, so an image-preferring paste target can't receive a stale
  image after a later text copy.
- **Ex count arithmetic saturates.** `:s`-count, range-count and address-offset
  paths use `saturating_add`/`saturating_sub` instead of unchecked `+`/`-`.
- **anvil `.rev` and checksum sidecars are written through
  `hjkl_fs::write_atomic`** instead of a hand-rolled fixed-name temp+rename
  (which raced concurrent writers, skipped fsync, and leaked staging files on
  failure).
- **bonsai staging cleanup handles file payloads.** The publish helper's
  lost-race/error cleanup used `remove_dir_all`, which fails on the compiled
  grammar `.so` staging _file_; cleanup now dispatches on the staged path's file
  type.
- **Shared→exclusive lock upgrades are rejected in debug builds.** The
  in-process lock layer records the outermost acquisition's mode and
  `debug_assert!`s on an upgrade attempt, which would otherwise silently drop
  cross-process exclusion.

### Changed

- **The non-atomic `:w` fallback's data-loss window is documented honestly.**
  The fallback (`File::create`, taken only when no temp file can be created
  beside the target) truncates before writing; doc comments claiming it could
  never lose data are corrected, and the path now has real test coverage.

### Removed

- Dead code swept by the 0.37.0 audit: `hjkl_config::write_default` (unused,
  bypassed the I/O seam), the unused `serde` feature plumbing in `hjkl-engine` /
  `hjkl-editor`, `hjkl-editor-tui`'s `crossterm` feature and its dead key-event
  bridge twin, and the drifted `km_to_crossterm` copy in the app (callers now
  use `hjkl_keymap_tui::to_crossterm`).

## [0.37.0] - 2026-07-26

### Fixed

- **Sending a file to trash across filesystems could destroy it.** When the
  trash directory was on a different filesystem from the file being deleted, the
  fallback copied straight into the reserved trash slot and then removed the
  original — so a copy that failed partway left a truncated copy that looked
  complete, with nothing to restore. The move now stages the copy, verifies it,
  and only then removes the source; it refuses to delete when the copy's size
  disagrees with the original. Directories, files and symlinks all take the same
  path, and the trash slot is never briefly empty.
- **Installing a tool across filesystems mangled symlinks.** The package-move
  fallback decided with `is_dir()` and copied with `fs::copy`, both of which
  follow links, so a symlink became a full copy of whatever it pointed at and a
  link to a directory was recursed into. Links are now reproduced as links, and
  an archive containing a fifo or device node fails cleanly instead of hanging
  forever.

### Added

`hjkl-fs` grows the primitives its consumers were re-deriving. All additive:

- **Owner-only handles** (#315) — `owner_only_options` and
  `owner_only_options_no_follow` for callers that hold a long-lived handle (an
  append stream, an incrementally written index) and need the same `0600` the
  crate gives swap and undo files.
- **Path confinement** (#317) — `canonicalize_nearest` and `resolve_under`. A
  `starts_with` check is fooled by a path that still holds unresolved `..`, and
  `canonicalize` cannot help because it fails on a path that does not exist yet,
  which is exactly the file-about-to-be-created case. These resolve the nearest
  existing ancestor and finish the remainder lexically, and never return an
  unprocessed `..`.
- **Directory operations** (#316) — `copy_dir_atomic` (staged, then swapped into
  place, so a failure never leaves a half-populated destination that looks
  complete), `move_atomic` (rename, falling back to copy-then-delete across
  filesystems) and `remove_path_all` (removes a symlink rather than deleting
  through it).
- **Open-handle identity** (#318) — `guard_not_swapped` answers what an
  `O_NOFOLLOW` open cannot: whether the handle you hold is still the object that
  path names. `hardlink_count` reports how many names share an object.

## [0.36.0] - 2026-07-26

### Added

- **`hjkl-fs` — one seam for every disk read and write.** hjkl's own state (swap
  files, undo history, the cursor index, trash, config) and your documents
  (`:w`, buffer loads) now go through a single crate, so atomicity, locking,
  permissions and size caps are decided once instead of being re-derived at each
  call site. It replaces seven separate hand-rolled temp-file-and-rename
  implementations that had drifted apart — only one of them carried the
  `O_NOFOLLOW` symlink guard, only two forced `0600`.
- **Ex-command completion** now covers far more ground: `:set opt=value` (with
  `no`/`inv` boolean prefixes), the map family and app-intercepted command
  names, typed argument kinds for `:cd` (directories only), `:syntax` and
  `:Anvil`, sub-commands for `:g`/`:v` and the `:cdo` family, `:colorscheme`
  with theme names, `~`/`$VAR` path expansion, paths containing spaces, and
  `<cfile>`/`<cword>`/`<cWORD>` expansion at the real caret.
- Benchmarks for the viewport render, cold undo-tree jumps and swap writes
  (`cargo bench -p hjkl-buffer-tui | -p hjkl-buffer | -p hjkl-app`).

### Fixed

- **Two hjkl instances no longer lose each other's data.** The cursor index is a
  single shared file, and its load-upsert-save was unlocked — two instances each
  loaded the same snapshot, mutated their own copy, and the second save silently
  discarded the first's entries. The same unlocked sequence existed at editor
  exit, where it rewrote the whole index and so discarded them wholesale. Swap
  files and undofiles are now locked too, and swap recovery holds one lock
  across read-decide-act: previously another instance could write a fresh swap
  between the read and the removal, and have it deleted — destroying its record
  of unsaved work.
- **Crash when highlighting multi-byte text.** A tree-sitter parse timeout keeps
  the previous tree, whose byte offsets can split a multi-byte character in the
  edited text; slicing there panicked
  (`byte_slice(): Byte range does not align with char boundaries`). All
  highlight-path slices are now snapped to character boundaries.
- **Grammar installs no longer certify a stale parser as fresh.** Any error was
  treated as success when the destination already existed, so a mid-copy
  out-of-space or missing source produced a "successful" install that the
  revision sidecar then vouched for.
- **Security audit fixes (2026-07-23):** stdin read capped at 256 MiB
  (`hjkl -`); `:make` and `:grep` honour `shell_disabled()`, so RPC modes
  without `--allow-shell` reject them; grammar `git_rev` validated for path
  separators and `..` traversal before the cache-directory join; fs-watch notify
  filter uses `try_lock()` and never blocks the raw event thread; macOS
  `AutoreleasePool` unsound `unsafe impl Send` removed; save's write-permission
  probe uses `O_NOFOLLOW`, closing a symlink-swap gap; LSP command validation
  rejects `..`; Wayland CMSG file-descriptor extraction is bounds-checked
  against the kernel-reported control length.
- Cross-session cursor and undo state resolve through one XDG implementation.
  `hjkl-config` carried a duplicate resolver; the layout is unchanged (`$XDG_*`
  when absolute, else `~/.config`, `~/.local/share`, `~/.cache`,
  `~/.local/state`) and uniform on Linux, macOS and Windows.

### Changed

- **Rendering and motions do less work per keystroke.** Syntax capture names are
  interned rather than allocated per span, span metadata is boxed (80 to 40
  bytes per span), highlight-cache eviction is O(1), search highlighting reuses
  the engine's per-row match cache instead of re-running the regex every frame,
  word motions cache the line per row and pre-parse `iskeyword` once per motion,
  and the viewport's per-frame line buffers are reused instead of reallocated.
- `hjkl -` rejects input over its 256 MiB cap with a clear "too large" error
  instead of failing obscurely when the cut landed mid-character.

## [0.35.0] - 2026-07-22

### Added

- **Undo tree with persistence.** Undo history is now a branching tree instead
  of a linear stack: editing after an undo keeps the old redo branch, `g-`/`g+`
  traverse every state by change number across branches, and `:undolist` shows
  the branch tips. The tree is delta-encoded and persisted to an `undofile`
  (content-hash gated), so undo/redo **and the current position survive closing
  and reopening a file**; a crash `:recover` restores the undo tree too. New
  settings: `undofile`, `undodir`.
- **Cross-session cursor memory.** Reopening a file restores the last cursor
  position on that buffer (the last-moved window wins across splits),
  best-effort and clamped to the file. New setting: `restore_cursor`.
- **Bundled color themes.** Six named themes — `tokyonight` (new default),
  `catppuccin`, `gruvbox`, `nord`, `dracula`, `onedark` — selectable with
  `:colorscheme <name>` or `theme.name`. Themes now drive the terminal
  background, with a `theme.transparent` override (nvim-style) that lets the
  terminal background show through.
- **`:normal[!]` ex command**, including `:[range]normal` and
  `:g`/`:v`/…/`normal`.
- **`;` ex range separator** (`:/foo/;/foo/d`): the cursor moves to the first
  address before the second is resolved, matching vim.
- **Magic `~`** (last substitute string) in search/substitute patterns.
- **Visual-block parity.** True blockwise register yank/paste (pastes as
  columns, not new lines); full block-operator coverage
  (`C`/`D`/`X`/`Y`/`S`/`R`, `[count]I`/`[count]A`, visual-mode block paste); and
  dot-repeat (`.`) for visual-block operators.
- **Dot-repeat for charwise/linewise Visual `r`.**
- **RPC/headless hardening controls:** `--allow-shell` gates shell-out (`:!`,
  `:r !`, range filters), and embed/nvim-api file I/O is confined to the working
  directory.

### Changed

- **`:colorscheme` now switches UI chrome and syntax together** (previously it
  recolored syntax only) and accepts any bundled theme name; `config.theme.name`
  is applied at startup (it was previously ignored). The default color theme is
  now `tokyonight`.
- **Undo storage** was reworked from linear undo/redo stacks into a
  delta-encoded arena tree with a materialization cache (internal; in-session
  behavior is unchanged apart from the new tree/branching features above).
- **`:g`/`:v`, `:normal`, and macro replay are each a single undo step** now,
  instead of one undo step per affected line.

### Removed

- Early-development backward-compatibility scaffolding: the deprecated
  `Editor::buffer_mark`/`buffer_marks` accessors, the `hover_popup` and
  `which_key` re-export shims, the `HjklMode` / `hjkl_vim::MotionKind` aliases,
  and the `push_buffer_cursor_to_textarea` no-op.

### Fixed

- **`dap` at end of buffer** now absorbs the leading blank line (`:h ap`), and a
  linewise Visual delete reaching the last line no longer parks the cursor on a
  phantom trailing row.
- **Config lock on non-Linux:** an unprobeable owner PID is no longer treated as
  dead, so a live lock is not falsely reclaimed on macOS/Windows.
- **LSP:** a language server configured with an empty `command` is rejected
  before spawn.
- **Save permission-probe TOCTOU** closed with a single non-truncating open.
- **Audit hardening:** Config persistence retains live locks; grammar manifests
  reject escaping source paths and use full pinned revisions for cache identity;
  failed async grammar dispatches resolve callers; LSP exits clear runtime
  attachments; swap recovery caps bodies; watcher backend failures trigger a
  rescan; and non-TUI modes validate default configuration while rejecting
  unsupported `--config` and `--clean` flags.
- **Swap temp file uniqueness:** `write_swap` now creates a unique per-call
  temporary file (`<name>.swp.<random>.tmp`) with `O_EXCL` (`create_new`) and
  Unix mode `0o600`, instead of reusing a predictable `<name>.swp.tmp` path.
  This prevents concurrent writers from interleaving writes to the same temp
  file and closes a window where a pre-existing temp file with loose permissions
  could be opened without tightening them.
- **Atomic config writes:** `write_key_at` now writes atomically (temp-file +
  `fsync` + `rename`) so a crash or I/O error mid-write never leaves a partial
  config. A lock file (`<config>.lock`) with retry prevents concurrent hjkl
  processes from silently losing each other's updates.
- **Crash-safe file writes for headless and embed/RPC modes:** `:w` in headless
  mode and the embed/RPC `save` command now use `save_file_durable` (atomic
  temp-file write + fsync + rename) instead of `std::fs::write`, which truncated
  the target in place. A crash or ENOSPC mid-write can no longer destroy the
  file in those modes.
- **Trash destination TOCTOU (reopened window closed):** `trash_path` now
  reserves per entry-type — a `create_new` file placeholder for files, an atomic
  `create_dir` for directories — kept in place so the caller's `rename` replaces
  it atomically. The previous `remove_file`-after-`create_new` revert had
  reopened the TOCTOU window for concurrent trash operations.
- **Save fallback no longer truncates on post-open failure:**
  `save_file_durable` now gates the in-place fallback to pre-write errors only
  (unwritable parent, cross-device rename). If the temp file is successfully
  opened, a subsequent write or sync error propagates instead of falling through
  to `File::create(path)`, which would truncate the original file on a disk-full
  condition.
- **Stale config lock file recovery:** `LockGuard::acquire` now records the
  owner PID and a timestamp in the lock file and checks both on contention. A
  lock whose owner PID is dead or whose mtime exceeds 60 seconds is reclaimed
  rather than failing permanently, preventing a crashed hjkl process from
  silently bricking runtime config persistence.
- **Parent-directory fsync after atomic rename:** All three atomic writers
  (`save_file_durable`, `write_swap`, `atomic_write` in hjkl-config) now fsync
  the parent directory after the final `rename`, so the rename itself survives a
  crash.
- **Cross-device directory restore test coverage:** Added
  `restore_dir_cross_device_fallback_preserves_contents` test that directly
  exercises the `copy_dir_recursive` + `remove_dir_all` fallback body of
  `move_dir` for a directory restore. The existing apply→undo→redo test only
  covered the same-device `rename` path.
- **Trash directory permissions:** `trash_dir` now sets Unix mode `0o700` after
  creation, matching the swap directory. Previously the effective mode depended
  on umask, potentially exposing trashed file contents to other local users.
- **Explorer redo treats restored directories as files:** The redo path in
  `apply_applied` for `AppliedOp::Restored` now checks `is_dir()` on the trashed
  entry and calls `move_dir` for directories (instead of always calling
  `move_file`, whose cross-device fallback cannot move directories).

## [0.34.2] - 2026-07-17

Two full-codebase audit rounds (~60 fixes, every one regression-tested with
unit + e2e coverage; the vim-parity oracle held at exactly 57 throughout).

### Fixed

- **Crash / data-loss class:**
  - Self-referential macros (`qaj@aq`-style) overflowed the stack; huge counts
    (`999999999@a`) allocated gigabytes. Replay is now an iterative, bounded
    queue with a vim-style `E169` abort.
  - Explorer renames silently clobbered existing files (a two-file name swap
    destroyed one file's content). Renames onto an occupied destination are
    refused; same-batch swaps route through a temp name; open buffers retarget
    to the renamed path (`:w` no longer forks the old path).
  - Language servers were orphaned on quit and never reaped when a language's
    last buffer closed; the nvim-api server also skipped teardown.
  - Diff mode could panic (`rope.line` past end) after insert-path edits — the
    event loop's inline sync blocks had drifted from the canonical sync path.
  - Bare `Ctrl-C` with dirty buffers exited AND deleted their swap files (the
    only recovery copy); it now refuses like `:qa`.
- **Silent desync class (tree-sitter / LSP / sibling splits):**
  - The `Edit → ContentEdit` mapping emitted wrong byte ranges for line joins
    (insert-mode Backspace at col 0, `gJ`), linewise deletes ending at the last
    row, visual-block deletes, and the undo-side shapes (`SplitLines`, block
    insert/delete chunks) — silently corrupting incremental reparse, LSP
    didChange, sibling-cursor rebase, and fold invalidation. All shapes are now
    byte-exact (property-checked against the buffer).
  - `:substitute` never sent LSP didChange; multi-file workspace edits left
    non-focused buffers' servers stale; multi-edit workspace batches could
    corrupt the server document (now full-sync fallback).
  - **UTF-16 position encoding**: positions now convert at every LSP wire
    crossing per the server's negotiated encoding — UTF-16-only servers
    (pyright, tsserver, lua-ls) no longer corrupt buffers or misplace
    diagnostics on multibyte lines.
  - `nvim_buf_set_lines`/`set_text` skipped the whole sync chain.
- **Vim parity:**
  - Dot-repeat honored `[count].` for insert changes; `:+N`/`:-N` relative
    addresses; backward ranges error `E493`; `:s/…/\r/` cursor lands on the last
    changed line; visual-block `A` appends per-row (not top-row-clamped), `I`
    skips short rows, `$` selects ragged to-EOL; `{count}gt` is absolute;
    `q{0-9}` records into numbered registers; `"+p` reads the OS clipboard; text
    objects use char (not byte) columns; global marks / search pattern /
    changelist now shared across windows at vim's scopes.
  - Manual folds row-shift with edits; undo restores marks/jumplist/changelist;
    the cursor is never stranded on a fold-hidden row.
- **Mouse / render:** picker mouse support was dead code; click math ignored
  soft wrap; popup anchoring ignored folds and stale viewports; `screen_rect`
  used the focused pane instead of the terminal; smooth scroll was visually
  inert with misaligned overlays; diagnostics/EOL hints rescanned every
  diagnostic per frame (now viewport-clipped).
- **Syntax (bonsai):** injection span cache reused wrong-language spans for
  identical content; query directive indices miscounted string/bracket patterns;
  1-byte comment delimiters (`#`) lost marker tints above the viewport.
- **Misc:** `gcc`/`:%!` no longer rebuild the whole document per keystroke
  (O(edit), not O(document)); swap files now cover all dirty buffers, not just
  the focused one; tab-close no longer leaks window state; headless mode shares
  registers/marks across files; `hjkl-anvil` tests run fully parallel (env-var
  collisions root-caused and removed).

## [0.34.1] - 2026-07-16

### Fixed

- **Release pipeline** — 0.34.1 is the first fully-published build of the 0.34
  line; 0.34.0 only published partially to crates.io before the release job
  failed. Two release-only defects fixed:
  - The crates.io publish step still listed the deleted `hjkl-css-gui` crate.
  - The `hjkl-vim` dev-dependency on `hjkl-engine` / `hjkl-engine-tui` /
    `hjkl-editor` carried a `^0.34` version requirement (via
    `.workspace = true`) that deadlocked the first publish of a new minor —
    `hjkl-vim@0.34` does not exist on crates.io until this release, and it is
    published after those crates. Made those dev-deps path-only so
    `cargo publish` drops them.

  No source changes vs 0.34.0 — same rename and removals (see below).

## [0.34.0] - 2026-07-16

### Changed

- **BREAKING — core type rename to fix a Document/View mislabel** (#279). The
  layering was a Document/View split whose two lower names sat one level off.
  Renamed the three core types so the code reads as vim's model:
  - `Content` (rope + folds + dirty — the document) → **`Buffer`**
  - `Buffer` (`Arc<Content>` + cursor — a view handle) → **`View`** (both the
    struct `hjkl_buffer::Buffer` and the engine's `Buffer` trait become `View`)
  - `Editor` is unchanged — the split/window is already named `Window`
    (`hjkl_layout::Window`); `Editor` remains the per-window editing context.

  Downstream consumers of `hjkl-engine` / `hjkl-editor` that name `Content` or
  the `Buffer` struct/trait must update to `Buffer` / `View`. Crate names,
  method/field names, and compound identifiers (`BufferView`, `BufferId`,
  `BufferEdit`, `ContentEdit`, …) are unchanged.

### Removed

- **Non-vim editing disciplines.** hjkl is vim-only again — its identity is a
  vim alternative, not an everything-editor. Removed the experimental VSCode
  non-modal discipline (the `apps/hjkl/src/app/vscode.rs` dispatcher and its pty
  tests) and the `hjkl-helix` crate (helix keymap/discipline). The engine's
  `KeybindingMode` enum, the `editor.keybindings` config option, and the
  `--keybindings` CLI flag are gone; the editor always runs the vim discipline.
- **GUI stack.** Dropped GUI support entirely. Removed the `apps/hjkl-gui`
  binary and the seven floem/cosmic-text adapter crates (`hjkl-css-gui`,
  `hjkl-editor-gui`, `hjkl-keymap-gui`, `hjkl-menu-gui`, `hjkl-prompt-gui`,
  `hjkl-syntax-gui`, `hjkl-tabs-gui`) plus their workspace-dependency entries.
  `floem` and `cosmic-text` are no longer in the build.

### Retained

- **Multi-cursor / secondary-cursor engine primitives** (`Sel`,
  `extra_selections`, `edit_at_all_cursors`, `selection_shift`) stay — they are
  a vim enhancement (real vim lacks them), kept for a future vim multi-cursor
  binding, and were never entangled with the removed disciplines.
- The engine's `DisciplineState` seam is kept as-is (vim installs through it),
  preserving the engine↔vim decoupling from #267.

## [0.33.6] - 2026-07-15

### Added

- **Helix editing discipline** (`hjkl-helix`): a selection-first keymap where
  motions extend a live selection that operators then act on. Real Helix
  behavior — counts, word/WORD selections (`w`/`W`/`e`/`b`), operators, `g` goto
  submaps, and `f`/`t` find — not a vim reskin (#63).
- **Multi-cursor / secondary cursors** in the engine: secondary cursors are
  tracked across edits, line joins, and block edits, and each carries an anchor
  (`Sel`) so operators act on the full range rather than a bare caret. Edits now
  apply at every cursor (#63).
- **Discipline-facing public API** on `Editor`: a stable seam disciplines
  (vim/helix) build against, decoupling keymaps from engine internals (#265).

### Fixed

- **HTML `<script>`/`<style>` highlighting**: `hjkl-bonsai` now accepts the
  colon-less `; inherits html_tags` modeline that nvim-treesitter's `html` query
  uses, restoring the default `<script>`→JavaScript and `<style>`→CSS
  injections. A bare `<script>` tag previously rendered unhighlighted.
- **Cursorline follows the active window's own cursor**: the cursorline
  background now keys off the per-window editor cursor (the same single source
  of truth as relative line numbers and indent guides) instead of the shared
  slot buffer's cursor, which could diverge and paint the wrong row in splits.

### Changed

- **Engine/vim seam extraction** (#265, #267): moved the vim FSM into
  `hjkl-vim`, hoisted engine-owned state (undo/redo, search, jumplist, viewport
  flags, scroll, autopair, abbreviations, marks) out of `VimState` behind a
  `Discipline` seam and the new public API, defaulted `Editor` to `NoDiscipline`
  with vim installed explicitly, and split the 10k-line `vim.rs` into a `vim/`
  module tree. Internal restructuring with no user-facing behavior change.
- Dependency bumps: `regex` 1.12.4 → 1.13.0, `pollster` 0.4.0 → 1.0.1,
  `cosmic-text` pinned to floem's version, plus a patch-group update.

## [0.33.5] - 2026-07-12

### Added

- **Context-aware clipboard backend selection** (`hjkl-clipboard`):
  `Clipboard::new()` now selects OSC 52 in an SSH session (`SSH_TTY` /
  `SSH_CONNECTION` / `SSH_CLIENT`) so yanks reach the user's **local** terminal
  clipboard instead of the remote host's native clipboard (tmux DCS passthrough
  applied automatically when `TMUX` is set). Local desktops keep the native
  probe. The `HJKL_CLIPBOARD` override is extended (case-insensitive) with
  `native`/`auto` (force the local-desktop probe) and the backend names
  `wayland` / `x11` / `macos` / `windows`, alongside `osc52`.

### Security

- **Blocked ripgrep/grep option injection in `:grep`**: the quickfix `:grep` /
  `:lgrep` pattern is now passed after a `--` separator, so a pattern beginning
  with `-` can no longer be parsed as an option (e.g. ripgrep's `--pre=<cmd>`,
  which executes an arbitrary command). The live-grep picker was fixed the same
  way. See also `hjkl-picker`.
- **Terminal escape sequences in file contents are now neutralized on render**:
  the buffer view mapped raw bytes straight into terminal cells, so a file
  containing control sequences (e.g. an OSC 52 clipboard write or a title-spoof)
  took effect merely from viewing it. C0/C1 control characters and DEL now
  render as visible single-width glyphs. See `hjkl-buffer-tui`.
- **Explorer reconcile can no longer escape its root**: a buffer line naming
  `../evil` (or an absolute path) had the name joined onto the parent verbatim,
  letting a create/rename/trash op target a path outside the explorer tree.
  Names with parent-dir, root, or drive-prefix components are now rejected;
  internal slashes for nested creation are still allowed.

### Fixed

- **Swap files are created owner-only (`0600`)** on Unix, and the swap directory
  `0700`. They hold unsaved buffer contents (potentially credentials or private
  keys) and were previously world-readable in a traversable cache directory.
- **Vim-parity: register routing.** Added the small-delete register `"-`;
  sub-line deletes (`x`, `dw`, `de`) now fill `"-` instead of rotating the
  `"1`–`"9` ring, a named-register delete (`"add`) leaves the ring and `"-`
  untouched, and yanking into a named register (`"ayy`) no longer clobbers `"0`.
  `"-` is now a usable paste selector (`"-p`).
- **Vim-parity: dot-repeat count.** `[count].` now _replaces_ the change's
  stored count instead of multiplying it (`3x` then `2.` deletes 2, not 6); a
  bare `.` still reuses the original count.
- **Vim-parity: word motions.** `w`/`W` now advance off an empty line, and
  `dw`/`dW`/`yw` on the last word of a line stop at end-of-line instead of
  eating the newline into the next line (`:h word` special case); counted `d2w`
  ending mid-line on a later row is unaffected.
- **Vim-parity: `%`** scans forward on the line to the first bracket at/after
  the cursor before jumping to its match (was a no-op unless the cursor already
  sat on a bracket); uses vim's default `matchpairs` (`()`, `[]`, `{}`).
- **Vim-parity: counted text objects.** `iw`/`aw`/`ip`/`ap`/`is`/`as` now honor
  the operator count (`d2aw`, `2ip`, `d2as`, …).
- **Vim-parity: `cw`/`aw`/`t` edge cases.** `cw`/`cW` act like `ce`/`cE` only
  when the cursor is on a non-blank (on whitespace they behave like `dw`); `aw`
  on whitespace now includes the following word; and `;`/`,` (and the 2nd..Nth
  step of a counted `t`/`T`) skip an immediately-adjacent match instead of
  sticking.
- **Vim-parity: `:substitute` replacement.** `\r`/`\t`/`\n` now insert a line
  break / tab / NUL (was the literal letters), a literal `$` stays literal,
  capture refs are emitted braced (`${1}`) so a digit can follow, `\0` is the
  whole match, and the cursor lands on the first non-blank of the last changed
  line.

### Internal

- Made the `hjkl` app's explorer/render tests deterministic under a parallel
  `cargo test` run (they raced on process-global `set_current_dir`,
  `XDG_CACHE_HOME`, and a pid-keyed scratch directory; `cargo nextest`, which
  isolates each test in its own process, was unaffected). Added a `test_cwd`
  module with `CwdGuard`/`EnvVarGuard` and a per-call unique scratch dir.

## [0.33.4] - 2026-07-09

### Fixed

- **markdown-tui: table column alignment with multi-width characters** (#270):
  emojis, CJK, and other wide Unicode characters now contribute their terminal
  display width (via `unicode-width`) rather than their scalar `char` count when
  measuring table columns, padding cells, and word-wrapping content. Table box
  borders correctly align regardless of cell contents.

### Changed

- **cargo-deny policy updates**: added `CDLA-Permissive-2.0` and `BSL-1.0` to
  the license allow-list (transitive via `webpki-root-certs` and clipboard/error
  crates, respectively), and ignored the `ttf-parser` unmaintained advisory
  (floem GUI stack only; not linked into the TUI binary).
- Dependency bumps for 68 crates across the workspace.

### Internal

- Moved the marketing landing page to `www.kryptic.sh/hjkl/`.
- Fixed a flaky e2e test on macOS (pty write ordering after bare Esc).

## [0.33.3] - 2026-07-01

### Added

- **Mode-selectable undo granularity** (#265, V1): undo checkpointing is now a
  discipline-agnostic engine setting (`Settings.undo_granularity`:
  `InsertSession` | `Word`). Vim keeps `InsertSession` (one undo per insert
  session — byte-identical, the default); VSCode mode uses `Word`, so `Ctrl+Z`
  reverts one word/line at a time (word starts + newlines open a new undo unit)
  instead of the whole typing session. Reusable by future FSMs (emacs, …).
- **`hjkl-kitty` crate + kitty keyboard protocol** (#265): new toolkit-agnostic
  (crossterm-only) crate exposing `enable`/`disable` (push/pop
  `DISAMBIGUATE_ESCAPE_CODES`, unconditional — no blocking capability query) and
  a `normalize_legacy` helper (maps disambiguated `Ctrl+[`/`Ctrl+I`/`Ctrl+M`
  back to `Esc`/`Tab`/`Enter`). hjkl now enables the protocol; the vim
  discipline normalizes those keys so muscle-memory is unchanged, while
  non-modal disciplines receive the raw disambiguated keys. Reusable by other
  kryptic-sh TUIs (e.g. sqeel). Unsupported terminals ignore the escape and keep
  legacy behavior.
- **VSCode mode — comment / indent chords** (#265): with the kitty protocol on,
  `Ctrl+/` toggles line comments, `Ctrl+]` / `Ctrl+[` indent / outdent, and
  `Ctrl+Backspace` deletes the word before the caret.
- **VSCode keybinding mode (pilot, non-modal)** (#265): `--keybindings vscode`
  (or `editor.keybindings = "vscode"`) selects a non-modal discipline — typing,
  navigation (arrows/Home/End/PageUp-Down), Backspace/Delete/Tab/Enter, and
  Ctrl+S save / Ctrl+Z undo / Ctrl+Y redo — with the status badge reading
  `EDITOR`. First non-vim discipline through the pluggable-FSM seam; vim stays
  the default and is unchanged. Selection, clipboard chords, find, and
  multi-cursor are tracked follow-ups in #265. The bottom-left mode badge is now
  discipline-aware (modal disciplines show their mode; non-modal show a static
  label).
- **VSCode mode — text selection** (#265, V5): Shift+Arrows / Shift+Home/End
  extend a selection, Ctrl+A selects all, plain arrows collapse it, and typing /
  Backspace / Delete over a selection replaces it. Selections use **exclusive**
  (bar-caret) semantics. Visual mode gained an opt-in `selection_exclusive`
  setting — inclusive (block-cursor) stays the vim default; vscode mode renders
  and edits half-open ranges so the cell under the caret is not included.
- **VSCode mode — clipboard chords** (#265, V6): `Ctrl+C` copy, `Ctrl+X` cut,
  `Ctrl+V` paste, operating on the exclusive selection. Copy/cut write to both
  the system clipboard and the unnamed register; paste reads the system
  clipboard and falls back to the register when the clipboard is unreadable
  (e.g. headless), so in-session cut→paste always round-trips. Paste replaces an
  active selection. `Ctrl+C` with no selection still interrupts/quits (terminal
  convention); empty-selection line-cut is a follow-up.
- **VSCode mode — find** (#265, V7): `Ctrl+F` opens the incremental search
  prompt (seeded with the active selection, if any); `F3` / `Shift+F3` jump to
  the next / previous match. Reuses the existing search infrastructure.
- **nvim-api: selection readout** (#265, V10a):
  `nvim_call_function("getpos", …)` now supports `"."` (cursor) and `"v"`
  (visual-selection anchor), and `line("v")` / `col("v")` return the anchor — so
  a headless client can reconstruct the active selection from anchor + cursor +
  mode. Positions use the 1-based char-col convention of the existing `col()`.
  Unblocks the planned Monaco conformance oracle.
- **VSCode mode — word motions + line-cut** (#265): `Ctrl+Left` / `Ctrl+Right`
  word-wise caret nav, `Ctrl+Shift+Left` / `Ctrl+Shift+Right` word-wise
  selection, `Ctrl+Delete` delete-word-forward, and `Ctrl+X` with no selection
  cuts the whole line. (Ctrl+/ comment and Ctrl+] / Ctrl+[ indent need the kitty
  keyboard protocol — terminal-aliased today — tracked separately on #265.)

### Fixed

- **`>>` / `<{motion}` indent now honors `noexpandtab`** (#263): the indent
  operator always inserted spaces, ignoring `expandtab`. Under `noexpandtab` it
  now inserts hard tabs (`width / tabstop` tabs plus a sub-tab remainder in
  spaces); `expandtab` is unchanged.
- **`:tabprevious` works (and `:tabp` / `:tabN` / `:tabNext`)** (#263): the
  command was registered under the canonical name `tabprev`, so the full vim
  word `:tabprevious` never matched the prefix table and silently did nothing —
  leaving the wrong tab/buffer focused. The canonical name is now the full
  `tabprevious`. Re-added the three #263 oracle cases (dropped in 0.33.2) as the
  regression gate; they pass on all platforms.

### Added

- **Per-filetype color for tabline icons** (#260): each tab's filetype icon is
  now painted with its devicon color (via `hjkl_icons::file_color_for_path`),
  matching the explorer tree, instead of inheriting the tab's monochrome fg.
  `hjkl_tabs::Tab` gained optional `icon` / `icon_color` fields and the
  `hjkl-tabs-tui` renderer paints the icon as its own span (kept colored on the
  active tab too, mirroring the explorer). Still gated on `:set tabline_icons`
  (default on).

### Fixed

- **Multi-line paste into Insert mode no longer bunches lines together**: the
  TUI did not enable terminal bracketed paste, so in raw mode crossterm mapped a
  pasted `\n` (0x0A) to Ctrl+J — which the Insert-mode dispatcher dropped —
  collapsing every pasted line onto one. The binary now enables bracketed paste
  (`EnableBracketedPaste`) and handles `Event::Paste`: the whole blob is
  inserted verbatim at the cursor with newlines preserved and no autoindent
  (correct paste behaviour); CRLF/CR are normalised to LF. New e2e coverage
  pastes a multi-line `.toml` into Insert mode and asserts the on-disk lines
  survive.
- **`hjkl --nvim-api` clipboard no longer corrupts the rpc stream** (#264, root
  cause): on a display-less host (e.g. the ubuntu CI runner) the editor's
  clipboard probe falls back to OSC 52, which writes terminal escape sequences
  to **stdout** — the same channel the msgpack-rpc protocol uses. A yank/delete
  in any session emitted escapes into the protocol stream, desyncing the peer's
  decoder and deadlocking both sides (the long-hunted ubuntu-only hang). The rpc
  servers (`--nvim-api`, `--embed`, `--headless`) now run **clipboard-free**
  (`host::disable_clipboard_for_rpc`); the embedding client owns the clipboard.
  The three subprocess oracle tests gated off in 0.33.2 are re-enabled and run
  unconditionally on all platforms again.

## [0.33.2] - 2026-06-25

### Fixed

- **`hjkl --nvim-api` no longer spins / hangs on pipe close** (#264): the
  msgpack-rpc read loop only exited on `UnexpectedEof`; any other stdin read
  error (broken pipe / connection reset when the client disconnects) fell
  through to `continue`, spin-looping at 100% CPU instead of exiting. It now
  exits on any read I/O error.

### Internal

- CI hardening (#264): a hard per-test `slow-timeout` in nextest so a deadlocked
  test is killed + named instead of hanging the job; the `hjkl --nvim-api`
  subprocess oracle tests are serialized and made opt-in via
  `HJKL_ORACLE_NVIM_API` (they hang on the ubuntu CI runner — tracked in #264).
- Release hygiene: `hjkl-buffer` and `hjkl-engine` were pinned to hardcoded
  package versions (`0.33.0` / `0.32.1`) instead of inheriting the workspace
  version like the other 58 crates, so a workspace bump left them stale (and
  `hjkl-engine`'s changes wouldn't have published). Both now use
  `version.workspace = true`; the `hjkl-engine` workspace-dependency req was
  widened to `0.33`. (0.33.1 was tagged but never published — superseded here.)

## [0.33.0] - 2026-06-24

### Added

- **Quickfix & location lists** (#184): full `:c*` / `:l*` command family —
  `:copen`/`:cclose`, `:cnext`/`:cprev`/`:cfirst`/`:clast`/`:cc`, navigation
  binds `]q`/`[q`/`]l`/`[l`, and a bottom-pane popup. Population via `:grep`,
  `:make` (with `:set errorformat`), `:cexpr`/`:cgetexpr`/`:caddexpr`,
  `:cbuffer`/`:cfile` (and `:l*` variants), `:colder`/`:cnewer` list stack,
  `:cdo`/`:cfdo` batch ops, and `:diagnostics`/`:ldiagnostics` from LSP. `gr`
  references populate the location list. New `hjkl-quickfix` crate holds the
  renderer-agnostic list model + errorformat parser.
- **Headless multi-buffer nvim-api** (#261): `hjkl --nvim-api` now drives the
  full multi-buffer app instead of a single bare editor. New surface: buffers
  (`nvim_list_bufs`/`create_buf`/`set_current_buf`/`buf_get_set_name`/
  `buf_get_set_lines`/`buf_get_set_text`/`buf_line_count`/`get_set_current_line`),
  windows (`nvim_list_wins`/`set_current_win`/`win_get_set_buf`/`win_close`/
  `win_get_set_cursor`/`win_get_set_height_width`), tabpages
  (`nvim_list_tabpages`/`get_set_current_tabpage`/`tabpage_list_wins`),
  `nvim_call_function` (`getqflist`/`setqflist`/`getloclist`/`setloclist`/
  `bufnr`/`bufname`/`expand`/`line`/`col`), `nvim_get_set_option_value`,
  `g:`/`b:`/`w:` variables, `nvim_set_keymap`/`del_keymap`,
  `nvim_replace_termcodes`, `nvim_feedkeys`, `nvim_exec2`. Backed by 60+
  nvim-cross-checked oracle cases.
- **Diff mode** (#250): `:diffthis`/`:diffsplit`/`:diffoff`, line bands,
  char-level diffs, filler-line alignment + scrollbind; `]c`/`[c` change
  motions.
- **Motions**: `<Space>`/`<BS>` wrapping motions (`whichwrap=b,s`), `<C-h>` as
  `<BS>`/left in the engine, `gm` (middle of the screen line), and hop-style
  label jumps in Normal + Visual (#197).
- **Tabline filetype icons** + `tabline_icons` toggle (#200).
- **Opt-in smooth scrolling** for page/recenter motions (#195).
- **Dirty-buffer reload prompt** on external change + Windows PID liveness
  (#241).
- `:set errorformat` option.

### Changed

- **Per-window editors are authoritative** (#151 Phase D): per-buffer state
  (named marks, syntax fold ranges) moved to the shared `Content`; one register
  bank is shared across all editors; cursor/scroll read directly from the window
  editor instead of a mirrored copy; multi-window views of the same buffer
  rebase cursors on edits. Removed the `layout::Window` cursor/scroll mirror.

### Fixed

- **UTF-8 char-boundary crashes**: the paste/completion-prefix slice, the HTML
  auto-close-tag scan (`scan_tag_opener`), status-line filename truncation, and
  the LSP `token_between` slice all used char indices as byte offsets and
  panicked on multibyte content (nerd icons, box-drawing, accents). Audited +
  fixed workspace-wide with regression tests.
- **Deterministic headless input** (#262): `nvim_input`/`nvim_feedkeys` now run
  the canonical post-edit sync instead of a full reconcile, so the cursor is no
  longer clobbered by a window-editor rebuild (was platform-divergent).
- splash animation anchor persistence across frames; `hjkl-fs-watch` macOS
  flake.

## [0.32.0] - 2026-06-11

### Added

- **Event-driven autoreload** (#242): open buffers now reload from disk the
  moment an external change lands, instead of waiting for `:checktime` or a
  focus-regain poll. Built on `hjkl-fs-watch`, which watches each open file's
  directory (non-recursively, so a large repo doesn't register tens of thousands
  of inotify watches). Honours the existing `autoreload`/`ar` option and the
  dirty-buffer guard — unsaved edits are never clobbered. `hjkl-fs-watch` gained
  `Watcher::watch_path` / `unwatch_path` and rootless `WatcherBuilder::build`
  for per-file watching.

### Changed

- **Explorer is now lazy-loaded (Snacks-style).** A directory's children are
  read from disk only when you expand it; collapsed dirs aren't walked. Opening
  the explorer in a huge directory (a home dir) is instant and light — no more
  whole-tree walk, no node cap, no truncation warning. Buffer folds are gone:
  the node list _is_ the visible tree, so expand/collapse adds/removes rows.
  Whole-tree search is provided by the gitignore-aware fuzzy file finder
  (`<leader>f`, now bound inside the explorer too); selecting a result opens the
  file and reveals it in the tree, lazily expanding its ancestors. Moving a
  collapsed directory (`dd` then `p`) is lossless — `CreateDir` now restores the
  whole trashed subtree by name, not just files.

### Fixed

- Creating a multi-level entry in the explorer (`o`, type `somedir/test.txt`,
  `<Esc>`) now auto-expands the new directory so the freshly-created file is
  visible, instead of leaving `somedir/` collapsed.
- Explorer name coloring is correct while typing a path with a `/` (e.g.
  `somedir/test.txt`): the git-bg / filetype highlight now spans the whole
  displayed name (derived from the live line) instead of just `file_name()`.
- `<Esc>` in BLAME view mode now exits back to Normal. The app-level Esc handler
  toggled the which-key display and returned before the key reached the engine's
  `exit_blame`, so BLAME stayed stuck on.

## [0.31.0] - 2026-06-08

### Added

- **Oil-style editable file explorer**: the tree is now a real, editable buffer.
  Normal vim edits map to filesystem operations applied on return to Normal mode
  — `i`/`cw` rename, `dd` delete, `o`/`O` create (trailing `/` = directory,
  `a/b.rs` = nested), `dd`+`p` move. Deletions go to a recoverable trash;
  `u`/`<C-r>` reverse the filesystem operations (journaled, not buffer-undo).
  The buffer holds the bare tree; icons, guides, and git colors are painted as a
  render overlay.
- **Stable in-text ids**: each explorer line carries a hidden, concealed id, so
  reconcile keys off identity rather than indentation. Editing indentation can
  no longer corrupt the tree (worst case is an undoable move), and filenames
  with internal/trailing whitespace round-trip correctly.
- **`p` on a directory moves the cut entry into it** — `dd` a dir/file then `p`
  on another dir is a real move (contents preserved via trash-restore).
- **Git-aware explorer**: file/dir names carry git-status background colors with
  live retag; `dd` on a tracked file keeps it red (pending deletion). `ga`
  stage/unstage, `gr` discard, `gc` commit are bound through the app keymap so
  which-key works in the sidebar.
- **Directory folds over a full-tree buffer**: collapse/expand via folds so `/`
  search + `n`/`N` traverse and reveal the whole tree; `/` is the normal buffer
  search (the fuzzy filter was removed). `<CR>` is the only expand/collapse/open
  key; `h`/`l` move the cursor; `<C-s>` opens in a vsplit, `<C-S-s>` in a split,
  `<C-t>` in a tab (so `<C-v>` is free for visual block).
- **`:debug` ex command** (and `hjkl +debug` launch flag): a global debug
  toggle. The explorer renders its raw buffer (no overlay/conceal) while it's
  on.
- Insert-mode abbreviations (`:ab`/`:iab`/`:cab` and their `un`/`no` variants).
- Broad vim-compat additions: `gp`/`gP`, `]p`/`[p`, visual `p`/`P`, `gn`/`gN`,
  `&`, `:m`/`:t`, visual `Ctrl-a`/`Ctrl-x`; visual `J`/`gJ`, `[count]%`,
  operator-indent dot-repeat; unmatched-bracket motions, hex increment;
  operator + search motion (`d`/`c`/`y` with `/` and `?`) and `R` dot-repeat;
  `[count]u`/`[count]<C-r>`, `g?` ROT13, `<C-r>.`/`<C-r>/` in insert.
- `:set name=on|off|true|false` accepted for boolean options.

### Changed

- `nomodifiable` (blocks all edits, incl. entering insert) is now distinct from
  `readonly` (edits allowed, error on save) — vim semantics. Files owned by
  another user but readable open editable and error on `:w` (so `:saveas`
  works).
- Yank/delete registers are global across buffers (`yy` in one buffer, `p` in
  another).
- A linewise operator (`dd`/`yy`/`cc`/`>>`/`gUU`/…) on a **closed fold** now
  acts on the whole fold, and `p`/`P` paste relative to the fold — vim parity.

### Fixed

- `O` autoindent copies the **current** line's indent, not the line above (vim
  parity); affects all `O` use, fixed an explorer reparent bug.
- Explorer mouse: clicks are fold-aware and use each window's own scroll origin
  (no offset on unfocused panes); a click resolves before focus-change so the
  focus auto-scroll can't shift it; clicking a directory folds/unfolds, a file
  opens.
- Explorer reveal-on-open is in-place (no fold collapse, correct row highlight,
  no spurious scroll when the file is already visible); `dd` on an open
  directory deletes the whole subtree instead of orphaning its children to the
  root; activating an empty directory is a no-op; `o`/`O` create a child vs.
  sibling correctly; `o`/`O`+`<Esc>` leaves no blank line; icon color no longer
  inherits the cursorline.
- LSP: char-boundary-safe identifier column (no panic on nerd-icon glyphs);
  requests gated on server capability + initialized state; stale pending
  requests time out so the status spinner can't hang; buffer-word completion
  always shown.
- Format-on-save: stale-check by content (not `dirty_gen`); external formatter
  detection is spawn-only; trailing newline stripped (no phantom line); empty
  project root no longer misreported as "not installed".
- Render/cursor: software cursor no longer trails or blinks during scroll; the
  insert bar uses the hardware cursor so the char under it stays visible.
- ex: `%`/`#` are not expanded inside a range or a substitute/global body.
- `di{`/`ci{` inner-block exclusive-motion adjustment; `[count]J` count
  semantics; Backspace/Esc honor the engine's pending chord (pop one level /
  cancel).

## [0.30.0] - 2026-06-04

### Added

- **Window-level folds**: fold open/closed state is now per-window. Two windows
  on the same buffer fold independently; a `:split`/`:vsplit` duplicate inherits
  the source window's folds (vim default), and editing text invalidates
  overlapping folds in sibling windows. Implemented via the existing focus-swap
  rails (`Buffer::set_folds` installs the focused window's set; saved back after
  dispatch). New `BufferView.folds_override` renders unfocused windows against
  their own snapshot.
- File explorer (#55): file operations and richer navigation. New keys when the
  explorer is focused — `a` create (trailing `/` makes a directory), `r` rename,
  `d` delete (with a `[y/N]` confirm), `y`/`x` copy/cut and `p` paste (move on
  cut, recursive copy for directories); `s`/`v`/`t` open the file under the
  cursor in a horizontal split, vertical split, or new tab; `R` refresh, `H`
  toggle hidden dotfiles (hidden by default now), `-` set the tree root to the
  parent directory. Opening the explorer reveals the active buffer's file
  (expands its ancestors and places the cursor on it). Create/rename use an
  in-status text prompt; delete uses a confirm prompt.
- Explorer fuzzy search: a vim-editable `/` search rendered as a persistent,
  rounded neo-tree-style box at the top of the pane. It filters the tree
  (keeping ancestors), honors `.gitignore` by default (`I` toggles), focuses the
  highest-scoring match, and supports mouse + `k`/`Up`-into-the-box. `/` is now
  per-buffer overridable (`open_search_prompt`) so buffers can supply their own
  search — a seam for future plugins.
- New `hjkl-fuzzy` crate: the picker's scorer extracted into a standalone,
  renderer-agnostic crate, now ranked by match-percentage (coverage) as the
  primary key.
- Per-buffer feature toggles (`BufferFeatures`: `syntax`/`lsp`/`hover`/
  `end_of_buffer`); the explorer disables all four. The end-of-buffer `~`
  markers (vim `fillchars=eob:`) are toggleable per buffer via
  `BufferView.show_eob`.
- Stable gutter: the line-number column is sized to the widest open buffer and
  the sign/fold columns are reserved across all buffers, so switching buffers no
  longer jiggles the text column.
- Tab bar / buffer line: a clickable `✕` close icon on each entry, and a
  distinct raised background for inactive tabs/buffers.
- Auto-folds for multi-line Rust `macro_invocation` (e.g. `concat!( … )`) and
  `use a::{ … };` declarations.

### Changed

- Fold-header background is a distinct violet tint (was a blue-grey too close to
  the cursorline). When the cursor row (or an unfocused window's ghost cursor
  row) lands on a closed fold header, the two backgrounds are blended.

### Fixed

- Per-window render correctness: relative/hybrid line numbers, the cursorline
  "ghost", matchparen, the active indent-guide column, and visual selection now
  key off each window's own saved cursor (or are gated to the focused window)
  instead of chasing the active window.
- Fold-aware scrolloff: the scroll margin is measured in screen rows, so the
  viewport tracks the cursor correctly around closed folds.
- Explorer: `switch_to` never loads a buffer into the explorer window; clicking
  a buffer in the buffer-line maps past the explorer slot; switching buffers
  scrolls the explorer so the revealed row stays in view; the read-only scratch
  buffer no longer blocks `:qa` or writes a swap file.
- Mouse clicks map through the actual rendered gutter width (correct column on
  buffers with a widened gutter).

### Performance

- Scrolloff on fold-heavy files is O(height), not O(n²) in the jump distance —
  fixes a noticeable lag on `G` (and other big jumps) in real source files with
  many tree-sitter auto-folds.
- Explorer fuzzy search runs off-thread with a debounce.

## [0.29.0] - 2026-06-02

### Added

- File explorer sidebar (#55): a Snacks.nvim-style tree pane, toggled with
  `<leader>e`. It is backed by a **real read-only hjkl buffer in a left
  window**, so all editor niceties work natively — motions, `/`+`n`/`N` search,
  visual mode, marks, registers, viewport scroll, and mouse. The tree renders a
  root node, `├╴`/`└╴`/`│` guides, and filetype/dir icons; `Enter`/`l` expands a
  dir or opens a file in the main window (explorer stays open), `h` collapses.
  Move between the explorer and the editor with `Ctrl-w h`/`Ctrl-w l` (and the
  `Ctrl-h`/`Ctrl-l` shortcuts) like any vim pane. The sidebar keeps a fixed
  column width across editor resizes, and is excluded from `:bnext`/`:bprev` and
  the buffer picker. New `BufferSlot.is_explorer`.
- Git blame (#202): cursor-line blame as inline end-of-line ghost text, served
  off-thread by a per-buffer-coalescing `BlameWorker` and debounced on cursor
  movement (no matter what moves the cursor). `:set blame_inline` (default on)
  toggles it; `:Gblame` pops the full commit for the cursor line;
  `:GitBlame`/`<leader>gm` toggle a blame view. New end-of-line virtual-text
  (`EolHint`) render support.
- BLAME view mode (`gb`): a first-class read-only FSM **view-mode**
  (`hjkl_engine::ViewMode`), orthogonal to the vim mode, with its own status
  label. Esc exits; any mode-changing key exits BLAME and applies. Commit runs
  are framed in-buffer as titled boxes (replacing the old sidebar). Hovering a
  commit (code, box body, or the box header row) shows the full commit message
  in the markdown hover popup; `K` in BLAME mode invokes the commit popup
  instead of the LSP hover. Editing is blocked while in BLAME. Mouse clicks and
  the matchparen overlay are box-aware.
- Icon mapping crate `hjkl-icons`: a pure filetype/dir → icon crate with three
  sets — `nerd` (patched-font glyphs), `unicode` (geometric `▸ ▾ ·`, renders in
  any monospace font), and `ascii` (`> v`, works everywhere). New
  `[editor] icons` config (`auto`/`nerd`/`unicode`/`ascii`); `auto` assumes a
  Nerd Font (terminals can't be queried for their font). Consumed by the file
  explorer.
- Folds — markers and tree-sitter auto-folds (#246): `foldmethod=marker` for vim
  `{{{`/`}}}` marker folds, with configurable `:set foldmarker`/`fmr` and
  `#region`/`#endregion` recognition. Tree-sitter `folds.scm` queries ship for
  rust, python, js, ts, go, c, cpp, json, bash, lua, java, html, css, scss,
  yaml, toml, ruby, php, c_sharp, and markdown, driving automatic folds.
- Git hunks (#115): stage/unstage individual hunks via `git apply`, with
  staged/unstaged gutter signs and an adaptive gutter menu. New `:GitStage`,
  `:GitRevert`, `:GitDiff` commands; right-clicking the gutter opens a
  Stage/Revert/Show-Hunk menu (#114 P6/P10), and left-clicking a git sign
  previews the hunk. Right-clicking a diagnostic sign opens a Show-Diagnostic +
  Code-Actions menu.
- Inline diagnostics: diagnostics render inline at end-of-line in a comment
  style with severity colors, controlled by
  `:set diagnostics_inline=off|current|all`. Diagnostic spans are drawn as
  severity-colored underlines (red/orange/green/cyan).
- Unified completion (#202 follow-up): live `:`-command completion through the
  shared completion popup (with docs; the old wildmenu is retired), a
  buffer-words provider that augments LSP with unique tokens from open buffers,
  fuzzy match ranking (exact > prefix > contiguous > scattered), auto-completion
  of identifiers/keywords as you type (not only after `.`), a directional popup
  that puts the best match nearest the anchor line when flipped above, and
  Enter-to-accept with arrow navigation.
- LSP: rust-analyzer now runs with clippy on save (`initializationOptions` +
  `didSave`). Format-on-save and LSP are enabled by default (config can
  disable).
- Faint cursorline on unfocused windows: the cursorline stays visible (dimmer,
  no cursor block) in windows that don't have focus. New
  `ui.cursor_line_inactive_bg` theme token.
- Fold gutter markers (#245): a 1-cell fold-indicator column now appears
  automatically whenever the buffer has any fold (no `:set foldcolumn` needed;
  an explicit `foldcolumn=N` still widens it, and files with no folds are
  unchanged). It paints `▾` at an open fold start, `▸` at a closed fold start,
  and `│` down an open fold's body. The gutter layout is now
  `[sign][number][fold][text]`; the renderer, cursor-block placement, matchparen
  overlays, and mouse hit-testing all account for the fold column.
- Matchparen now highlights the matching HTML/XML **tag pair** under the cursor
  (#243). When the cursor sits on an open or close tag name, both the `<tag>`
  and `</tag>` name runs get the same bold+reversed emphasis as bracket
  matching. Nested same-name tags pair by depth. Char-scan based (reuses the
  #182 tag resolver); gated by `:set matchparen`. New public
  `hjkl_engine::vim::matching_tag_pair` resolver and
  `App::matchparen_tag_cells`.
- Mouse: left-clicking a fold marker in the gutter now toggles that fold (#114
  P10, partial). Clicking a gutter row with no fold focuses the window but is
  otherwise a no-op. The git-sign hunk-preview half of P10 remains blocked on
  #115.

### Fixed

- Completion popup: the popup width is capped (and the per-item detail column
  truncated) so a single suggestion with a very long detail can no longer span
  the whole screen. The popup is also anchored to the focused window's rect
  instead of the buffer area, fixing its position when the explorer sidebar is
  open or the layout is split.
- Perf (mouse drag): syntax highlighting no longer recomputes per cursor step
  while dragging a visual selection with the mouse. The tree-sitter viewport
  query is skipped on pure cursor moves and deferred to at most one query per
  frame, cutting ~88% CPU during a mouse drag.
- Save: the syntax tree is reset after a format-on-save rewrite, avoiding a
  stale-tree `byte_slice` panic.
- Completion accept now routes through the tracked `mutate_edit` path so syntax
  and diagnostics resync after accepting an item (#143).
- Diagnostics: tree-sitter parse-error gutter signs are suppressed when an LSP
  owns diagnostics for the buffer, so the two sign sources no longer collide.
- Command line: typing a bare `:` no longer shows a completion popup, a single
  Esc closes the empty command line, and typing an exact alias/command runs it
  on the first Enter instead of accepting a longer candidate first.
- Git: fixed a char-boundary panic on `:Rename` and a blame repaint after async
  load.
- Swap: a closed buffer's swap file is now removed on `:bdelete`/`:bwipe` in a
  multi-buffer session.
- Folds (#244): closing or toggling a fold no longer corrupts syntax
  highlighting on the visible rows — the syntax span recompute now expands the
  requested doc-row range to account for rows hidden by closed folds, so spans
  cover the full visible window. Pending fold ops are drained on both the
  keyboard (`za`/`zc`) and gutter-click paths.
- Folds (#244): closing a fold with the cursor inside the fold body now snaps
  the cursor to the fold's first line (vim behaviour), so subsequent `j`/`k`
  step over the closed fold as a single unit instead of stranding the cursor on
  a hidden row. Fix lives in `Editor::apply_fold_op`, so keyboard and
  gutter-click paths share it.
- Folds (#244): the terminal cursor block is now painted on the correct screen
  row when closed folds sit above the cursor. `Editor::cursor_screen_pos` was
  computing a naive `pos_row - top_row` delta that ignored fold-collapsed rows,
  drawing the block N rows too low while the (fold-aware) text and line
  highlight rendered correctly. It now delegates its screen-row math to the
  single canonical `viewport_math::cursor_screen_row_from` calculator that
  already drives scrolling and scrolloff — one fold/wrap-aware source of truth
  instead of three.
- Folds (#244): `:<n>` (and every other `Editor::goto_line` caller — picker
  jump, LSP goto, open-file-at-line) now opens the closed fold(s) enclosing the
  target line via the new `Buffer::reveal_row`, so the cursor lands on a visible
  row instead of vanishing inside a collapsed fold. Handles nested folds in one
  pass.

## [0.28.1] - 2026-05-30

### Added

- Count-aware bracket text objects: `2di{` / `3di{` (and the `c` / `y`
  operators) now target the Nth enclosing pair, matching Vim.

### Fixed

- Bracket text objects (`di{`, `ci{`, `di}`, …) now operate correctly when the
  cursor sits on the closing bracket — previously a silent no-op.
- The matching-paren highlight is now rendered bold + reversed video, making the
  matched bracket clearly visible.

## [0.28.0] - 2026-05-25

### Breaking

- **`hjkl_buffer::Buffer` rope-only access.** Removed
  `Buffer::lines() -> Vec<String>`, `Buffer::line(row) -> Option<String>`,
  `Buffer::lines_range`, and `Buffer::line_bytes`. Callers go through
  `Buffer::rope() -> ropey::Rope` (O(1) Arc-clone) plus the new free helpers
  `hjkl_buffer::{rope_line_str, rope_line_bytes}`. Test asserts can use
  `RopeSlice: PartialEq<str>` for zero-alloc compares.

### Added

- `hjkl_buffer::rope_line_str` / `rope_line_bytes` free helpers exported from
  the crate root.
- Rope-native bonsai API:
  `Highlighter::{parse_initial_rope, parse_incremental_rope, highlight_range_rope, highlight_range_with_injections_rope}` +
  `Source<'a>` enum on `MatchContext` so predicates resolve capture text from
  either `&[u8]` or `&Rope` backends without materializing the full document.
- Inline hex-color preview overlay — `#rgb` / `#rrggbb` literals render with the
  actual colour as background and a contrast-matched foreground (#224).
- HTML/XML auto-rename matching tag pair on Esc (#182).
- `:set <opt>?` query form prints the current value (#227).
- HTML opening-tag smartindent — Enter inside `<div>|</div>` bumps indent and
  drops the closing tag onto its own dedented line (#225).
- Markdown / doc-comment code-fence expansion — ` ```lang⏎ ` expands to a full
  fence pair (#222).
- Autopair brackets/quotes + auto-close HTML/XML tags (#181).
- Comment continuation on Enter / `o` / `O` (#180).
- `gc{motion}` / `gcc` comment toggle operator (#187).
- `:syntax on|off` ex command toggles bonsai highlighting app-wide (#112).

### Changed

- Tree-sitter parses now stream from the rope via `Parser::parse_with_options`'s
  chunk callback — no contiguous-bytes materialization for incremental parse.
- LSP `textDocument/didChange` uses incremental UTF-8 sync (negotiated in
  `initialize`); replacement text slices directly from the rope per edit instead
  of rebuilding the full document.
- Undo / redo snapshots are O(1) `Rope` clones (was `Arc<String>` requiring a
  full `to_string` build on every undo group boundary). `restore_text` diffs the
  old rope against the new text via `minimal_content_edit_rope`
  (`bytes_at(n).reversed()` + `bytes()`).
- Event loop batches sync tree-sitter reparse: every keystroke arm sets
  `App::pending_recompute = true` instead of calling `recompute_and_install()`
  inline; a single flush fires at the top of the next iteration before
  `terminal.draw`. Bursts of typing fold N queued keys into one parse + draw.
- Git diff worker takes `Rope` (O(1) Arc-clone) and materializes bytes on the
  worker thread instead of on the UI thread.
- `mimalloc` wired as the global allocator and routed into tree-sitter via
  `ts_set_allocator` so TS-internal C allocations also hit mimalloc.

### Performance

- Per-keystroke CPU on multi-MB files cut roughly in half on typing and
  substantially more on burst typing vs. v0.27.0.
- `render_viewport` dropped `Buffer::content_joined()` — rope-native marker and
  hex-color passes materialize only the window they scan, and the highlight
  query uses tree-sitter's `TextProvider` closure form against rope chunks.
- `buffer_signature` streams `rope.chunks()` into `AHasher::write` — no
  full-document materialization for the dirty-check.
- `buffer_signature` length pre-compare + ahash cached against `dirty_gen`;
  `Buffer::byte_len` cached against `dirty_gen`.
- Search count uses `rope.line_to_byte(row)` (O(log N)) for cursor's global byte
  position instead of a linear newline scan.
- Completion-popup prefix recompute uses `buffer.line(cur_row)` (O(log N))
  instead of cloning the entire document.
- Cached compiled regex in `LuaMatchPredicate` (was 43 % CPU on hot files).
- Viewport-bounded `build_by_row` and `Buffer::lines_range` for the TUI
  renderer.
- `Highlighter::edit` no longer clears the child span cache (cache is
  content-hash-keyed and self-validating).
- `render::frame` no longer calls `recompute_and_install()` — the event loop's
  pre-draw flush handles it. Was a redundant per-frame sync TS parse on every
  draw.
- `Engine::byte_offset` / `pos_at_byte` use `rope.line_to_byte` /
  `rope.byte_to_line` (O(log N)) instead of per-row String materialization.

### Fixed

- `VisualLine` `r_hi` could exceed `rope.len_lines()` in `editor::end_step` and
  the vim FSM — added clamp. Proptest seed captured.
- `ctrl_scroll_keys_do_not_panic` had `!e.buffer().row_count() == 0` (precedence
  bug: `!x == 0` ≠ `!(x == 0)`); fixed to `> 0`.
- Stale syntax spans on row-count change (#231).
- Push-parse dropping queued edits on replace (#232).
- Macro recording: insert-mode keys recorded at the correct dispatch hook;
  replay falls through to the engine; pending-state reducer wires through insert
  dispatch (#216 / #217 / #218 / #220).
- `gcc` silently no-ops on file open — filetype now auto-detected (#221).
- Triple-backtick produced 4 backticks (#229).
- Autopair pair-on-third-of-triple-quote suppression (#230).
- bonsai `injections.scm` with directive-only injection language (#223).
- `:e!` and formatter paths reset syntax client and clear pending edits.
- `web/404.html` closing tag was `</test>` instead of `</head>`.

## [0.27.0] - 2026-05-20

### Added

- `!` filter operator — `!{motion}`, `!!`, and visual-mode `!` pipe the selected
  range through an external shell command. `Editor::filter_range` spawns
  `sh -c <cmd>` (or `cmd /C` on Windows), streams stdin/stdout on drain threads
  to avoid pipe deadlock, and SIGKILLs the child after a configurable timeout
  (default 10s) without mutating the buffer on error. Five engine tests cover
  identity (`cat`), `sort` full + partial range, bad-command Err, and
  slow-command timeout-kill. Closes #85.

- `N|` column-jump motion — `5|` moves to column 5 (1-based, clamped to line
  length). Works as a charwise-exclusive motion under operators: `d5|`, `y|`,
  `c10|` all behave correctly. Oracle corpus extended with four cases. Closes
  #169.

- `"%` filename register and `:put` ex command. `<C-r>%` in insert mode inserts
  the buffer's filename inline; `:echo @%` prints it; `"%p` pastes it. `:put a`
  pastes register `a` as a new line below the cursor; `:put!` pastes above.
  Closes #170.

- `:saveas` / `:sav` write the buffer to a new path AND update the buffer
  identity so subsequent `:w` writes there (distinct from bare `:w <path>` which
  only writes elsewhere). `:file newname` renames the buffer in-memory without
  writing; bare `:file` prints the current name + dirty/readonly status. Closes
  #172.

- `:cd` / `:pwd` working-directory commands. `:cd /path` calls
  `std::env::set_current_dir`; bare `:cd` goes to `$HOME`. `:pwd` prints the
  current working directory to the status bar. Closes #173.

- Uppercase global marks (`mA`–`mZ`) now record the source buffer and jump
  across files. `mA` in file A → `:e fileB` → `'A` switches back to file A and
  moves the cursor. `` `A `` restores the exact column. Lowercase marks
  (`ma`–`mz`) remain per-buffer. `:marks` listing shows `buf:{id}` beside
  uppercase entries. New `MarkJump` enum (`SameBuffer` /
  `CrossBuffer { buffer_id, row, col }` / `Unset`) returned from
  `try_goto_mark_char` / `try_goto_mark_line` so the app layer dispatches the
  cross-buffer switch. `EditorSnapshot` VERSION bumped 4 → 5; older snapshots
  are not loadable. Closes #175.

- `:&` and `:&&` repeat the last `:s` substitution on the current line. `:&`
  drops flags; `:&&` keeps them. Range prefix works: `:%&&` repeats with flags
  on the whole buffer. The most recent `:s` is stored in
  `VimState::last_substitute`; ships the small half of #171 (interactive
  `:s///gc` confirm remains open in #171).

### Added

- Motions `[[`, `]]`, `[]`, `][`, `+` / `<CR>`, `-`, `_` (closes #31). Section
  header / end navigation by `{` / `}` at col 0; linewise first-non-blank moves.
  All seven support count prefix and operator-pending (`d[[`, `y]]`, `c+`, etc).
  Oracle corpus extended with one case per motion.

- Search pattern pre-processor rewrites vim word boundaries `\<` and `\>` to
  `\b` before regex compile. Drops the start/end asymmetry but unblocks the
  common case; users typing `/foo\<bar\>` now match `bar` as expected. Applies
  to `/` search, live-preview highlighting, and `:s` substitute. Closes #33.

- Added `editor.chord_timeout_ms` config field (default 1000 ms, vim's
  `timeoutlen` equivalent). Previously the chord-resolve timeout was derived
  internally as `which_key.delay_ms + 500`; users can now tune it independently.
  A startup warning is emitted when `chord_timeout_ms <= which_key.delay_ms` to
  flag a potential chord-resolve race. Closes #133.

- `<C-f>` mid-prompt switch into cmdline window. From `:` / `/` / `?` prompts,
  `<C-f>` captures the in-progress text + cursor col, closes the prompt without
  committing, and opens the matching `q:` / `q/` / `q?` window with the text
  appended as the trailing line, cursor at the captured column. `cedit` is
  hardcoded to `<C-f>` for v1 (configurable later). Closes #132.

### Changed

- Added 6 which-key popup tests covering app-wins-over-FSM
  (`which_key_app_wins_over_fsm_for_conflicting_key`), user-shadows-app
  (`which_key_user_shadows_app_for_conflicting_chord`), unmap removal from popup
  (`which_key_unmap_removes_entry_from_popup`), FSM-only key surfaces
  (`which_key_fsm_only_key_surfaces_in_popup`), Visual-mode suppression
  (`which_key_visual_mode_suppresses_popup`), and sticky-root FSM inclusion
  (`which_key_sticky_root_includes_fsm_entries`). Documented Normal-mode-only
  popup hardcoding in `render.rs::which_key_popup` and
  `mod.rs::active_which_key_prefix`. Audit follow-up — behavior was already
  correct.

- `hjkl-statusline` polish — `Cow<'static, str>`-backed `Segment::Text` for
  zero-alloc static labels, `percent_segment` now takes a `ModeKind` parameter
  so the badge bg/fg echoes the active mode, `truncate_filename` uses
  `char_indices()` for correct UTF-8 char-boundary truncation on non-ASCII
  filenames, and diag-severity colors switched from hardcoded RGB to
  `StatusTheme`-routed fields (`diag_error_fg` / `diag_warning_fg` /
  `diag_info_fg` / `diag_hint_fg`) so the host controls the palette. Four new
  tests added: `readonly + dirty` combined, `percent_segment` with empty buffer,
  `Bar::layout` right-exceeds-width degenerate case, and recording-register
  segment. Closes #135.

- Extracted `hjkl-vim-tui` from `hjkl-vim` and moved engine's crossterm adapter
  to `hjkl-engine-tui`. `hjkl-engine` and `hjkl-vim` now have zero toolkit deps
  — fully agnostic cores. `hjkl-form`'s crossterm cascade dropped. The 231-test
  engine integration suite moved to `hjkl-engine-tui/tests/`. Closes #162 phase
  3 (the engine cleanup arc).

- Workspace `resolver = "2"` → `"3"` for MSRV-aware dep resolution.
  `cargo update` now skips transitive crate versions whose `rust-version`
  exceeds the workspace MSRV (1.95), preventing accidental MSRV breakage. Closes
  #144.

## [0.25.1] - 2026-05-19

### Fixed

- **Missing `hjkl-css` and `hjkl-css-gui` publish on v0.25.0** — the
  `publish_if_missing` list in `.github/workflows/ci.yml` predated those crates,
  so they were silently skipped from the v0.25.0 push. The list now includes
  both. v0.25.1 ships them at the lockstep version; consumers can pin `"0.25"`
  and pick them up.

### Changed

- Renamed crate `hjkl-css-floem` → `hjkl-css-gui` to match the `-gui`
  floem-adapter convention. External consumers update the dep name and the
  `use hjkl_css_floem::*` import.
- Renamed crate `hjkl-keymap-crossterm` → `hjkl-keymap-tui` to match the `-tui`
  adapter convention (crossterm is a TUI-only input lib).
- Extracted `hjkl-splash-tui` from `hjkl-splash` — the ratatui adapter is no
  longer behind a feature gate on the agnostic crate. Apps must depend on
  `hjkl-splash-tui` directly for `render`. Part of #162.
- Extracted `hjkl-buffer-tui` from `hjkl-buffer` — the ratatui Widget impl is no
  longer behind a feature gate on the agnostic crate. Direct consumers
  (`hjkl-syntax-tui`, `hjkl-picker-tui`) depend on `hjkl-buffer-tui` directly.
  Part of #162.
- Dropped dead `ratatui` feature from `hjkl-vim`. Four engine-API tests that
  lived in `hjkl-vim/tests/vim_fsm.rs` (gated on that feature) relocated to
  `hjkl-engine`'s `mod tests` where the implementation lives. No consumer asked
  for `hjkl-vim["ratatui"]`. Part of #162.
- `hjkl-engine`: collapsed dual `style_table` representation into a single
  engine-native `Vec<Style>` field. Public ratatui adapter methods
  (`intern_ratatui_style`, `install_ratatui_syntax_spans`,
  `ratatui_style_table`) now convert at the boundary. Storage is no longer
  cfg-mutex'd. Phase 1 of #162.
- Extracted `hjkl-engine-tui` from `hjkl-engine` — ratatui adapter surface
  (`style_to_ratatui` / `style_from_ratatui` free fns + `EditorRatatuiExt` trait
  with `intern_ratatui_style` / `install_ratatui_syntax_spans` /
  `ratatui_style_table`) now lives in the new sibling crate. `hjkl-engine`
  dropped its `ratatui` feature entirely. Phase 2 of #162.

### Removed

- Deleted deprecated `hjkl-ratatui` shim. Use `hjkl-editor-tui` directly.

## [0.25.0] - 2026-05-18

### Changed (BREAKING)

- **Data-model types relocated** to restore clean layering — renderer and worker
  crates no longer depend upward into `hjkl-app` (closes #160):
  - `Completion`, `CompletionItem`, `CompletionKind` → new `hjkl-completion`
    crate.
  - `GrammarRequest`, `LanguageDirectory` → new `hjkl-lang` crate.
  - `BufferId` → `hjkl-buffer`.
- `hjkl-completion-tui` and `hjkl-syntax` dropped their `hjkl-app` dep entirely.
  They now depend on the leaf data crates directly.
- **Migration** for external consumers of `hjkl-app`:
  - `use hjkl_app::completion::Completion` → `use hjkl_completion::Completion`
  - `use hjkl_app::lang::LanguageDirectory` → `use hjkl_lang::LanguageDirectory`
  - `use hjkl_app::BufferId` → `use hjkl_buffer::BufferId`
- Standardized 47 crate-level READMEs to the Tokio/wgpu monorepo convention — CI
  / crates.io / docs.rs / MSRV / License badges all point to the umbrella
  monorepo. Replaces stale badges that pointed to the standalone submodule repos
  absorbed in #152.

### Added

- `hjkl-completion` 0.25.0 — completion data model (10 unit + 2 doctests; zero
  renderer deps).
- `hjkl-lang` 0.25.0 — `GrammarRequest` + `LanguageDirectory` (5 unit + 2
  doctests).
- `BufferId` re-exported from `hjkl-buffer`'s lib root.
- **Rate-limit guard** in `publish_if_missing` (`.github/workflows/ci.yml`):
  sleep 25 s after each successful publish (47 × 25 s ≈ 20 min, under
  crates.io's 30/10min update cap), automatic retry-on-429 with cooldown parsed
  from the `try again after` timestamp (max 3 attempts). Eliminates manual
  reruns after rate-limit hits.

### Removed

- `hjkl-app::completion` module + `crates/hjkl-app/src/completion.rs` (moved to
  `hjkl-completion`).
- `hjkl-app::lang` module + `crates/hjkl-app/src/lang.rs` (moved to
  `hjkl-lang`).
- `hjkl-app::BufferId` (moved to `hjkl-buffer::BufferId`).

### Notes

- `hjkl-lsp` and `hjkl-mangler` still define their own `pub type BufferId = u64`
  aliases (pre-existing, unrelated to #160) — tracked as follow-up debt;
  unifying would force a `hjkl-buffer` dep on those crates just for a type
  alias.

## [0.24.4] - 2026-05-18

### Fixed

- **cron.yml YAML parse failure** — the v0.24.2 sed cleanup of
  `submodules: recursive` left 6 orphan empty `with:` blocks (followed
  immediately by `- uses:`), making the YAML invalid. GitHub showed the workflow
  as `name: ".github/workflows/cron.yml"` (file path fallback when parse fails)
  and emitted "No jobs were run" emails on every push. Dropped the 6 orphan
  `with:` lines; YAML now parses; 5 legitimate `with:` blocks with actual
  content remain intact.

## [0.24.3] - 2026-05-18

### Fixed

- **`[workspace.dependencies]` missing `version` fields** — every entry now
  carries `version = "0.24"` alongside `path = "..."`. Without this,
  `cargo publish` strips the `path` key and rejects the dependency with
  "dependency does not specify a version". The v0.24.2 publish-crates job
  published hjkl-xdg successfully then failed on hjkl-anvil; v0.24.3 clears all
  remaining 46 crates.

## [0.24.2] - 2026-05-18

### Fixed

- **Windows CI build** — `move_file_cross_device` in `hjkl-anvil/installer.rs`
  is only reachable on Unix (called from `#[cfg(unix)]` `atomic_symlink`). On
  Windows the function appeared dead → `-D warnings` failed the windows-latest
  test job. Added `#[cfg_attr(windows, allow(dead_code))]`. Without this fix, CI
  gates publish-crates indefinitely.

### Changed

- **cron.yml: removed 6 stale `submodules: recursive`** in
  miri/fuzz/deny/bench/wasm_size/vim_compat jobs. No-op at runtime (no
  submodules exist post-#152) but a maintenance confusion risk.

## [0.24.1] - 2026-05-18

### Changed

- **CI: 21 absorbed crates added to `publish_if_missing`** — the
  `publish-crates` job now publishes all 21 formerly-submodule crates in
  topological dependency order before the existing 26 in-tree crates (closes
  #152 Phase 3). Topo order: hjkl-xdg → hjkl-anvil → hjkl-theme → hjkl-bonsai →
  hjkl-buffer → hjkl-clipboard → hjkl-config → hjkl-engine → hjkl-editor →
  hjkl-keymap → hjkl-vim → hjkl-form → hjkl-editor-tui → hjkl-ex → hjkl-lsp →
  hjkl-mangler → hjkl-picker → hjkl-picker-tui → hjkl-ratatui → hjkl-splash →
  hjkl-theme-tui.
- **CI: `submodules: recursive` removed** from all `actions/checkout@v6` steps
  (fmt, clippy, test, no_std, build, publish-crates) — no submodules remain in
  the workspace after Phase 1 absorption.

## [0.24.0] - 2026-05-18

### Changed (MAJOR)

- **Lockstep workspace versioning** — all 48 workspace crates now share a single
  version via `version.workspace = true` in each `[package]` table (closes #152
  Phase 2). Bumped from `0.22.0` to `0.24.0` — v0.23.x tags were already
  occupied by prior umbrella releases; 0.24.0 is the next available minor.
- **`[workspace.dependencies]` block added** to root `Cargo.toml`: all 48
  hjkl-\* crates are declared via `path =` so inter-crate deps can use
  `hjkl-foo.workspace = true` or
  `hjkl-foo = { workspace = true, features = [...] }` instead of version
  strings.
- **`[patch.crates-io]` block removed** — the 48 entries were made redundant by
  workspace-relative `path =` deps. `cargo build --workspace` resolves all
  crates through the workspace member list directly.
- **`hjkl-splash`: `start_screen` module added** — exposes `StartScreen`,
  `StartScreenTheme`, and `render()` (ratatui-gated), fixing a pre-existing
  missing-module bug that was masked by the old `[patch.crates-io]` build cache.

### Migration

External consumers of the individual crates should pin to their previous
independent versions (e.g. `hjkl-engine = "0.11"`, `hjkl-vim = "0.23"`). Those
tags remain on crates.io. The 0.24.0+ series reflects monorepo lockstep going
forward — all crates advance together on each umbrella release.

## [0.22.0] - 2026-05-18

### Changed (MAJOR)

- **Monorepo absorption** — all 21 submodule crates merged into the umbrella
  repository via `git read-tree --prefix` (closes #152 Phase 1). Files moved
  from separate `kryptic-sh/hjkl-*` repos to `crates/<name>/` with file contents
  preserved (upstream commit history remains in the standalone
  kryptic-sh/hjkl-\* repos until Phase 4 deletion). `.gitmodules` deleted.
  Per-crate `.github/`, `rustfmt.toml`, `rust-toolchain.toml`, `deny.toml`,
  `.editorconfig`, `.gitignore`, and `hjkl-bonsai/.cargo/config.toml` removed —
  umbrella owns these configs. Per-crate `README.md`, `CHANGELOG.md`, `LICENSE`,
  and data files (`anvil.toml`, `bonsai.toml`, `themes/`, `DESIGN-0.4.0.md`)
  retained.
- **`[patch.crates-io]` block kept** — workspace members use plain version deps
  (`hjkl-engine = { version = "0.11" }`); the patch block remains required until
  Phase 2 converts all inter-crate deps to `version.workspace = true`.
- Workspace version bumped `0.21.35` → `0.22.0`. Per-crate versions unchanged
  (Phase 2 switches to lockstep `workspace.version = true`).

### Crates absorbed (21 total, versions as absorbed)

`hjkl-engine 0.11.4`, `hjkl-vim 0.23.2`, `hjkl-ex 0.5.1`, `hjkl-buffer 0.8.1`,
`hjkl-form 0.6.2`, `hjkl-picker 0.9.1`, `hjkl-anvil 0.2.4`, `hjkl-xdg 0.1.0`,
`hjkl-config 0.2.1`, `hjkl-splash 0.3.0`, `hjkl-lsp 0.1.1`, `hjkl-keymap 0.3.0`,
`hjkl-editor 0.8.1`, `hjkl-bonsai 0.7.5`, `hjkl-clipboard 0.5.5`,
`hjkl-theme 0.2.0`, `hjkl-mangler 0.1.0`, `hjkl-ratatui 0.7.0`,
`hjkl-editor-tui 0.1.0`, `hjkl-picker-tui 0.4.1`, `hjkl-theme-tui 0.1.1`.

### Notes

- `hjkl-clipboard`: absorbed at Cargo.toml version `0.5.5` (tag was `v0.5.4` — a
  known "corrects botched 0.5.4" situation; tag not re-cut per policy).
- `hjkl-editor-tui`: had a `.git/` directory (standalone clone) rather than the
  normal gitmodule link — absorbed cleanly via `git rm` + `git read-tree`.
- `hjkl-bonsai`: originally tracked as `hjkl-tree-sitter` in `.git/modules/`;
  `.cargo/config.toml` (xtask alias) deleted; `xtask/` sub-workspace kept.
- Submodule repos (`kryptic-sh/hjkl-*`) will be deleted in Phase 4 of #152 after
  monorepo CI is green.

## [0.21.35] - 2026-05-18

### Fixed

- **#158 P0 review findings** — multi-view UB in `hjkl-buffer 0.8.1`:
  `Buffer::lines()` / `Buffer::line()` / `folds()` / `fold_at_row()` now return
  owned data instead of references backed by a held `MutexGuard`. The prior
  unsafe lifetime extension was unsound when two `Buffer` views share the same
  `Arc<Mutex<Content>>`.
- All engine call sites updated (`hjkl-engine 0.11.4`): `Query::line` trait
  returns `String`; `buf_helpers`, `motions`, `buffer_impl`, `editor`, `search`,
  `vim`, `viewport_math`, and the app-layer (`apps/hjkl`) all adapted.
  Ex-command callers in `hjkl-ex 0.5.1` updated. Test helpers in
  `hjkl-vim 0.23.2` updated.
- Module-level doc for `hjkl-buffer/content.rs` corrected:
  `Arc<RefCell<Content>>` → `Arc<Mutex<Content>>`.

### Changed

- Submodule pointer bumps: `hjkl-buffer` → v0.8.1, `hjkl-engine` → v0.11.4,
  `hjkl-ex` → v0.5.1, `hjkl-editor` → v0.8.1, `hjkl-form` → v0.6.2,
  `hjkl-picker` → v0.9.1, `hjkl-picker-tui` → v0.4.1, `hjkl-vim` → v0.23.2.

## [0.21.34] - 2026-05-18

### Changed (BREAKING)

- **`hjkl-buffer 0.8.0`** — `Buffer` now holds `Arc<Mutex<Content>>` and
  represents a per-window view. `Content` is the new per-document type that owns
  the text rows (`lines`), dirty generation counter (`dirty_gen`), and manual
  folds. Migration: existing `Buffer::new()` / `Buffer::from_str()` /
  `Buffer::replace_all()` keep the same signatures (one `Buffer` per `Content`
  is the common case — no change for single-window use).

### Added

- `hjkl_buffer::Content` struct — per-document state that can be shared across
  multiple `Buffer` views via `Arc<Mutex<Content>>`. Exported from
  `hjkl-buffer`'s crate root.
- `Buffer::new_view(Arc<Mutex<Content>>) -> Buffer` — creates a second
  per-window view onto the same content. Each view carries its own cursor;
  text + folds are shared.
- `Buffer::content_arc() -> Arc<Mutex<Content>>` — returns a clone of the inner
  `Arc` for constructing additional views.
- Tests `buffer_views_independent_cursors` and `buffer_views_share_content` in
  `hjkl-buffer` verifying the split semantics (closes #158).

### Fixed

- `docs/editor-field-classification.md` — corrected `styled_spans` and
  `buffer_spans` Classification column from "per-buffer" to "per-window
  (near-term; per-document target in Phase D)"; updated per-window field count
  headline; updated VimState count from 35 to 36; added "Notable findings"
  section documenting the cursor-in-Buffer discovery and the Helix Document+View
  adoption.

## [0.21.33] - 2026-05-18

### Added

- `docs/editor-field-classification.md` — classification of every
  `hjkl_engine::Editor` and `VimState` field as per-buffer, per-window, or
  global, in preparation for the per-window Editor refactor (#153, closes #153).

## [0.21.32] - 2026-05-18

### Fixed

- `apps/hjkl` no longer calls `sync_viewport_to_editor()` before every keypress
  — that pre-keypress sync was the structural cause of the v0.21.27 sticky_col
  regression (the v0.21.31 fix made it safe via `set_cursor_quiet`, this removes
  the pre-keypress sync entirely). Editor state is now persisted into the
  focused window's snapshot after dispatch only.

### Changed

- Consolidated focus-change logic into `App::switch_focus(window_id)` and
  `App::switch_tab(tab_idx)` helpers. All focus/tab transitions now go through
  these instead of the open-coded `sync_from` → set focus → `sync_to` pattern
  (event_loop, ex_dispatch, mouse, prompt, window). Helpers are `pub(crate)`.

### Added

- 9 app-level cursor preservation tests in `apps/hjkl/src/app/tests/keymap.rs`
  covering j/k/gj/gk through empty + short lines, search `n`, mark jumps, and
  multi-window cursor isolation. These exercise the full event-loop dispatch
  path (which engine-level tests miss).

### Notes

- This addresses the sticky_col regression aspect of #151. The full per-window
  Editor refactor (shared `Arc<Buffer>`, `Window.cursor_row/col` removal,
  multi-window same-buffer broadcast) remains open as #151 and will ship in a
  later release cut into smaller phased PRs.

## [0.21.31] - 2026-05-18

### Fixed

- **`viewport_sync` no longer clobbers `sticky_col` on every keypress** —
  `sync_viewport_to_editor` was calling `Editor::jump_cursor`, which resets
  `sticky_col` (curswant) to the clamped cursor column. On an empty line that
  means `sticky_col` got overwritten with 0, so the next `j`/`k` landed at col 0
  instead of restoring the pre-jump column. Fixed by routing the viewport cursor
  restore through the new `Editor::set_cursor_quiet`, which positions the cursor
  without touching `sticky_col`. Root cause of the post-v0.21.27 `j`/`k`
  empty-line regression.

### Added

- **`Editor::set_cursor_quiet`** (`hjkl-engine` 0.11.3) — host-side cursor
  restore that preserves `sticky_col`. Use for viewport sync and snapshot
  replay; use `jump_cursor` for user-facing jumps (goto-line, search, picker
  `<CR>`, `]d`, click) where curswant reset is correct vim semantics.
- **Regression test `e_then_jjj_preserves_column_across_empty_line`** — covers
  the full event-loop path (`sync_viewport_to_editor` + dispatch +
  `sync_viewport_from_editor`) to guard against reintroduction of the
  viewport-sync sticky-col clobber.

## [0.21.30] - 2026-05-18

### Fixed

- **`move_screen_vertical` (`gj`/`gk` in wrap mode) preserves `sticky_col`
  across empty and short visual segments** — `sticky_col` was overwritten with
  the clamped cursor column after each motion, breaking subsequent `gj`/`gk`
  presses when crossing empty or narrow doc lines. Fixed by snapshotting `want`
  at entry and omitting the post-loop overwrite, matching `move_vertical` / vim
  curswant semantics.
- **Add READMEs to 21 in-tree crates** so `cargo publish` succeeds for all
  workspace members: `hjkl-editor-gui`, `hjkl-fs-watch`, `hjkl-holler`,
  `hjkl-holler-tui`, `hjkl-hover`, `hjkl-hover-tui`, `hjkl-info-popup`,
  `hjkl-info-popup-tui`, `hjkl-layout`, `hjkl-markdown`, `hjkl-markdown-tui`,
  `hjkl-menu`, `hjkl-menu-tui`, `hjkl-prompt`, `hjkl-prompt-tui`, `hjkl-syntax`,
  `hjkl-syntax-tui`, `hjkl-tabs`, `hjkl-tabs-tui`, `hjkl-which-key`,
  `hjkl-which-key-tui`.

### Added

- **5 cursor-position regression tests** covering `j`/`k`/`gj` through empty and
  short lines:
  - `move_down_preserves_sticky_col_across_empty_row` (hjkl-engine)
  - `j_through_empty_line_preserves_want` (hjkl-vim)
  - `j_through_multiple_empty_lines_preserves_want` (hjkl-vim)
  - `k_through_empty_line_preserves_want` (hjkl-vim)
  - `gj_through_empty_line_preserves_want_in_wrap_mode` (hjkl-vim)

## [0.21.29] - 2026-05-18

### Fixed

- **`hjkl-fs-watch` macOS test compatibility round 2** — universal
  `settle_watcher` helper (300 ms sleep + drain loop) applied after every
  watcher build in the test suite, eliminating spurious FSEvents
  `Created(tempdir)` events that leaked into assertions. All tests that
  constructed a watcher without a canonicalized root now use
  `dir.path().canonicalize()`, fixing `/var` → `/private/var` path-comparison
  mismatches on macOS. `rename_file_emits_renamed_or_remove_create` now accepts
  a third event shape: `Modified(src) + Modified(dst)` (FSEvents conflates
  rename as two modify events). `resume_restores_event_delivery` uses
  canonicalized paths so the post-resume `Modified` event matches correctly.

## [0.21.28] - 2026-05-18

### Fixed

- **`hjkl-which-key::layout()` early-return for empty entries** —
  `layout(&[], width)` previously returned `cols=7` (an artefact of the
  `unwrap_or(8)+2` sentinel path) instead of `cols=1, rows=0, popup_h=3`. The
  long-standing CI test `layout_empty_entries_gives_one_col` now passes on
  Ubuntu and Windows.
- **`hjkl-fs-watch` macOS test compatibility** — path canonicalization prevents
  symlink-prefix mismatches on `/var` → `/private/var`; delete-event assertion
  accepts `Modified` as well as `Removed` (FSEvents behaviour); init-event
  settle
  - drain loop before `recv_timeout` assertion; `pause()` / `resume()` / worker
    load upgraded to `Ordering::SeqCst` with a post-pause drain loop;
    rename-event collection timeout raised to 10 s.

### Changed

- **`hjkl-engine` 0.11.1** — `jump_cursor` sticky_col fix is now published to
  crates.io (was 4 commits ahead of the v0.11.0 tag; version bump required for
  publish).
- **`hjkl-clipboard` 0.5.5** — corrects botched v0.5.4 where the tag was pushed
  while the manifest still read `0.5.3`, blocking crates.io publish.

## [0.21.27] - 2026-05-18

### Fixed

- **`Editor::jump_cursor()` now resets `sticky_col` (vim curswant).** Fixes
  column-snap on `]d`/`[d`, LSP goto, picker jumps, marks, search hits, and ~30
  other call sites that delegated to the engine primitive. Previously
  `jump_cursor` moved the cursor but left `sticky_col` stale, so the next
  `j`/`k` snapped back to the old column.

- **`gg` / `{N}gg` now update `sticky_col` to first-non-blank col** per vim
  semantics. `apply_sticky_col` in `vim.rs` now uses the raw `buf_set_cursor_rc`
  primitive directly (instead of `jump_cursor`) so the un-clamped `want` column
  is correctly preserved across short lines during `j`/`k` vertical motion.

## [0.21.26] - 2026-05-18

### Changed

- **`hjkl-syntax` 0.1→0.2 — exhaustive dispatch helpers** — adds
  `SyntaxLayer::dispatch_load_event(event, handler)` and
  `SyntaxLayer::dispatch_parse_kind(kind, handler)` inherent methods together
  with matching `LoadEventKind<'_>` and `ParseKindKind` exhaustive enums.
  Consumers call these instead of matching directly on `#[non_exhaustive]`
  `LoadEvent` / `ParseKind`, eliminating wildcard arms. Updated
  `apps/hjkl/src/app/syntax_glue.rs` `poll_grammar_loads` and
  `install_render_result` to use the new dispatch API; wildcard arms removed.
  Purely additive — bumped to **0.2** per `0.x` minor policy.

- **`hjkl-menu` 0.1→0.2 — exhaustive dispatch** — adds
  `MenuAction::dispatch(handler)` method and `MenuActionKind` exhaustive enum
  mirroring all `MenuAction` variants. `apps/hjkl/src/app/mod.rs`
  `invoke_menu_action` now calls `action.dispatch(|kind| match kind { … })`
  rather than matching on `MenuAction` directly, removing the `_ => {}`
  wildcard. `crate::menu` shim re-exports `MenuActionKind`. Bumped to **0.2**
  per `0.x` minor policy.

- **`hjkl-layout`: `hit_test_border_tree` migrated to `Axis`** —
  `apps/hjkl/src/app/mouse.rs` `hit_test_border_tree` inner match on `SplitDir`
  replaced with `match dir.axis() { Axis::Col => …, Axis::Row => … }`,
  consistent with the other four `Axis`-based sites in `render.rs` and
  `window.rs`. `hjkl-layout` version unchanged (consumer-side change only).

- **`hjkl-fs-watch`: tighten rename test** —
  `rename_file_emits_renamed_or_remove_create` assertion changed from
  `has_rename || has_remove || has_create` (which passes on any single event) to
  `has_rename || (has_remove && has_create)` (requires the full from+to pair on
  the fallback path). Also improves error message to name exactly which
  condition was expected.

### Fixed

- **`## [0.21.25]` LOC figures retroactively corrected** —
  `apps/hjkl/src/syntax.rs` was ~1 521 lines before the extraction (not 59.5 K —
  that was the issue tracker's exaggerated total). The collapsed wiring shim is
  ~560 lines (not "~5 K"). Added note that #150 (GUI syntax layer) is deferred;
  `hjkl-syntax` renders plain text in GUI mode until that issue lands.

- **`## [0.21.23]` doctest counts retroactively corrected** — `hjkl-menu`
  shipped 36 unit tests + 13 doctests (not 37 + 12); `hjkl-menu-tui` shipped 13
  unit tests + 2 doctests, where 1 of the 2 doctests is `no_run`
  (compile-checked only, not executed in CI).

- **`hjkl-fs-watch` sliding-debounce doc** — added a "Debounce window" section
  to the module-level doc explaining the sliding-window behaviour: each new raw
  event resets the path's timer, so rapid bursts flush after the last event plus
  one debounce window.

## [0.21.25] - 2026-05-18

### Added

- **#141 Extract syntax pipeline into `hjkl-syntax` + `hjkl-syntax-tui`** —
  `apps/hjkl/src/syntax.rs` (~1 521 LOC pre-extraction) split into two new
  crates and a ~560 LOC wiring shim. (#150 GUI syntax layer deferred;
  `hjkl-syntax` renders plain text in GUI mode until that issue lands.)
  - **`hjkl-syntax` 0.1.0** — renderer-agnostic syntax pipeline. Owns
    `SyntaxWorker` background thread, `RenderCache`, per-buffer
    `dirty_gen`-tracked state, viewport-scoped highlight passes
    (`ParseKind { Viewport, Top, Bottom }` — ordering semantics and perf
    invariants preserved). Output:
    `RenderOutput { spans: Vec<Vec<(usize, usize, StyleSpec)>>, signs: Vec<DiagSign>, … }`
    where `DiagSign` is a renderer-agnostic diagnostic sign (row + ch +
    priority, no ratatui dep). Zero ratatui dependency. Re-exports
    `hjkl_theme::{Color, Modifiers, StyleSpec}`. All public enums/structs
    `#[non_exhaustive]`. `Default` + `new()` constructors. 19 unit tests (7
    require network/compiler, skipped in CI) + 14 doctests covering `ParseKind`
    ordering, queue deduplication, perf breakdown defaults, and the
    `build_by_row` helper.

  - **`hjkl-syntax-tui` 0.1.0** — ratatui adapter. `to_ratatui_spans` converts
    `Vec<Vec<(usize, usize, StyleSpec)>>` to
    `Vec<Vec<(usize, usize, ratatui::style::Style)>>`.
    `diag_signs_to_buffer_signs` materialises `DiagSign` → `hjkl_buffer::Sign`
    with canonical error colour (red). `render_output_to_tui` combines both. 11
    unit tests + 5 doctests.

  - **`apps/hjkl/src/syntax.rs`** — collapsed to App-side wiring shim (~560
    LOC): delegates all background-thread work to `hjkl_syntax::SyntaxLayer` and
    converts `RenderOutput` to the ratatui-typed wrapper on the way out.

  `#[non_exhaustive]` on `ParseKind` and `LoadEvent` required wildcard arms at
  `syntax_glue.rs` match sites (migrated to dispatch helpers in v0.21.26).
  `ParseKind` ordering (Viewport → Top → Bottom) is preserved — perf invariant
  intact.

## [0.21.24] - 2026-05-18

### Changed

- **#140 SplitDir exhaustiveness fix** — all
  `match dir { SplitDir::Horizontal => …, SplitDir::Vertical => …, _ => panic!() }`
  wildcard arms replaced with
  `match dir.axis() { Axis::Row => …, Axis::Col => … }`. `Axis` is a new
  exhaustive (non-`#[non_exhaustive]`) enum added to `hjkl-layout 0.3.0` with a
  `SplitDir::axis()` method. Matching on `Axis` gives the compiler full
  exhaustion checking from consumer crates; future `SplitDir` variants fall back
  to `Axis::Row` inside the crate with `#[allow(unreachable_patterns)]`. Touches
  `split_rect`, `draw_separator`, `render_layout` in `render.rs` and
  `update_matching` in `app/window.rs`. `hjkl-layout` bumped to **0.3.0**.

- **#131 prompt API cleanup (path B)** — `PromptOutcome` enum and
  `PromptState::handle_input` removed from `hjkl-prompt`; they were never wired
  to `apps/hjkl`'s `handle_command_field_key`. All prompt logic remains in
  `hjkl-prompt`'s `PromptState`, `apply_history_nav`, `advance_completion`;
  `PromptState` is still used for wildmenu rendering via `hjkl-prompt-tui`.
  `hjkl-prompt` bumped to **0.2.0** (breaking removal). Follow-up issue to wire
  `handle_command_field_key` through `PromptState` (path A, ~155 call sites).

- **#5 structured `ExEffect::InfoTitled`** — `ExEffect` gains
  `InfoTitled { title: &'static str, content: String }`. The four listing
  handlers (`:registers`, `:marks`, `:jumps`, `:changes`) in `hjkl-ex` now
  return `InfoTitled` instead of `Info`. `ex_dispatch.rs` in `apps/hjkl` matches
  `InfoTitled` directly to open an `InfoPopup` without brittle header-prefix
  string matching. `ExEffect::Info` remains for unclassified single-line or
  unlabelled multi-line output. `hjkl-ex` bumped to **0.5.0** (breaking variant
  addition; all consumers updated).

### Fixed

- **#20 grammar load error migrated to bus** — `GrammarLoadError` struct,
  `GRAMMAR_ERR_TTL` constant, `grammar_load_error: Option<GrammarLoadError>`
  field on `App`, and the inline status-line render block removed.
  `poll_grammar_loads` now calls `self.bus.error(format!("grammar {name}: …"))`
  on load failure so grammar errors surface via `:notifications` like all other
  bus messages.

- **#6 LSP restart severity** — `restart_lsp` now calls
  `bus.info("LSP restarted")` instead of `bus.warn` (successful restart is
  informational, not a warning).

- **holler-tui tautology test** — `render_active_empty_bus_no_panic` now uses
  `ratatui::backend::TestBackend` to actually invoke
  `render_active(frame, area, &bus, &layout, now)` rather than merely asserting
  on `bus.active(now).count()`.

## [0.21.23] - 2026-05-18

### Added

- **`hjkl-menu` 0.1.0** — renderer-agnostic context menu data model (closes
  #127). Public surface: `MenuAction` (`#[non_exhaustive]` — `Copy`, `Cut`,
  `Paste`, `TabClose*`, `LspGoto*`, `LspHover`, `LspRename`, `LspCodeActions`,
  `LspFormat`, `LspRestart`, `OpenFilePicker`, `WindowEqualize`, `WindowClose`,
  `Picker{Open,OpenSplit,OpenVSplit,OpenTab,CopyPath}`, `Separator`, `Info`;
  `is_inert()`); `MenuItem` (`#[non_exhaustive]`, fields: `label`, `action`,
  `enabled`, `shortcut_hint`; methods: `new()`, `separator()`,
  `is_selectable()`); `ContextMenu` (`#[non_exhaustive]`, fields: `items`,
  `selected`, `anchor`; `Default`; methods: `new()`, `move_up()`, `move_down()`
  — wraps at bottom, saturates at top, both skip inert rows,
  `selected_action()`, `dimensions()`, `bounding_rect(screen_w, screen_h)`);
  builder helpers: `build_code_menu(has_sel, has_lsp)`,
  `build_status_line_menu(ft, lsp_name)`, `build_split_border_menu()`,
  `build_picker_menu(has_path)`, `build_tab_menu(more_than_one_tab)`. Zero
  renderer dependencies. 36 unit tests + 13 doctests.
- **`hjkl-menu-tui` 0.1.0** — ratatui adapter for `hjkl-menu` (closes #127).
  Public surface: `MenuTheme` (`#[non_exhaustive]`, fields: `border`,
  `normal_fg`, `selected_fg`, `selected_bg`, `dimmed_fg`, `separator_fg`;
  `new()`, `Default` dark palette);
  `bounding_rect(menu, screen_size: Rect) -> Rect` — thin wrapper around
  `ContextMenu::bounding_rect` that adds the screen origin offset;
  `render(frame, menu, screen_size, theme)` — floating bordered popup with
  proper separator, info-header, disabled-dim, and hint-right-align rendering.
  13 unit tests + 2 doctests (1 of 2 is `no_run`: compile-checked only).

### Changed

- **`apps/hjkl/src/menu.rs` collapsed to shim** — 955-line file replaced by a
  12-line re-export of `hjkl-menu` and `hjkl-menu-tui`. All menu logic lives in
  the new crates; `crate::menu::*` call sites in `event_loop.rs`, `render.rs`,
  `app/mod.rs`, and tests are unchanged. `bounding_rect` calls updated from
  method-on-menu to free function from `hjkl-menu-tui`. `invoke_menu_action`
  match arm gains `_ => {}` wildcard for `#[non_exhaustive]` compliance.
  Behaviour delta: none.

## [0.21.21] - 2026-05-18

### Added

- **`hjkl-fs-watch` 0.1.0** — debounced filesystem watcher wrapper around
  `notify` (closes #16). Public surface: `Watcher` (owns the OS watcher +
  debounce thread; `try_recv()`, `recv_timeout()`, `events()` drain iterator,
  `pause()`/`resume()`/`is_paused()`), `FsEvent` (`#[non_exhaustive]` —
  `Created`, `Modified`, `Removed`, `Renamed { from, to }`), `WatcherBuilder`
  (`root()`, `filter()`, `debounce()`, `recursive()`, `build()`), and
  `WatchError` (`Notify`, `Io`, `MissingRoot`). Tokio-free: sync `std::thread`
  - `crossbeam-channel` backend; no async runtime required. Rename events use
    `notify`'s `RenameMode` hints with best-effort debounce-window merging; see
    `FsEvent` doc for platform caveats. No consumer migration in this release;
    available for `sqeel-tui`, `hjkl-picker`, and `hjkl-editor` when needed. 26
    unit + integration tests.

## [0.21.20] - 2026-05-18

### Added

- **`hjkl-tabs` 0.1.0** — renderer-agnostic tab bar data model (closes #13).
  Public surface: `Tab<Id>` (`#[non_exhaustive]`, fields: `id`, `title`,
  `dirty`; methods: `new()`, `display_label()` — prepends `●` when dirty,
  `cell_width()`); `TabBar<Id>` (`#[non_exhaustive]`, fields: `tabs`, `active`;
  methods: `new()`, `Default`, `len()`, `is_empty()`, `open(id, title)` —
  upsert + focus, `close(&id)` — removes tab and resets focus to nearest,
  `focus_next()` / `focus_prev()` — wrap-around cycle, `focus(&id)`,
  `active() -> Option<&Tab>`, `active_mut()`, `active_index()`,
  `visible(max_width: u16) -> (Vec<&Tab>, bool, bool)` — active-tab-centred
  scroll window with left/right overflow flags). Generic over `Id: Eq + Clone`.
  Zero renderer dependencies. Note: distinct from `hjkl_layout::Tab`
  (window-split tree); see module doc for disambiguation. 24 unit tests + 10
  doctests.
- **`hjkl-tabs-tui` 0.1.0** — ratatui adapter for `hjkl-tabs` (closes #13).
  Public surface: `TabBarTheme` (`#[non_exhaustive]`, fields: `active_fg`,
  `active_bg`, `inactive_fg`, `sep_fg`, `overflow_fg`; `new()` constructor,
  `Default` dark/catppuccin palette); `pub fn render(frame, area, bar, theme)` —
  renders one-row tab strip with bold active tab, `●` dirty prefix, `│`
  separators, `<`/`>` overflow indicators;
  `pub fn build_line(width, bar, theme) -> Line<'static>` — same output without
  requiring a `Frame`, useful for embedding or testing. 12 unit tests + 1
  doctest.

### Changed

- **`apps/hjkl` tab-bar migration**: `top_bar()` in `render.rs` now builds a
  `hjkl_tabs::TabBar<usize>` from `app.tabs` (layout tabs) and delegates the
  right-side tab rendering to `hjkl_tabs_tui::build_line`. The inline `TabEntry`
  struct + manual span loop (~40 LOC) is replaced by a shim that populates the
  data model and extracts spans from the crate. Behaviour is identical: same
  labels (`{n}: {name}`), same dirty marker, same active-tab highlight.
  Buffer-line left side unchanged.
- **`hjkl-app` 0.4.9 → 0.4.10**: patch bump for workspace alignment.

## [0.21.19] - 2026-05-18

### Changed

- **`hjkl-layout` 0.1 → 0.2** (breaking): `LayoutTree` gains `#[non_exhaustive]`
  — external match arms must include a wildcard catch-all. Closes #140.
- **`hjkl-hover` 0.1 → 0.2** (breaking): `HoverViewport` and `HoverRect` gain
  `#[non_exhaustive]` with matching `::new()` constructors. External struct
  literals must migrate to constructors. Closes #126.
- **`hjkl-hover-tui` 0.1 → 0.2**: updated to `hjkl-hover 0.2`; construction
  sites migrated to `HoverViewport::new()`.
- **`apps/hjkl` prompt-bar wiring** (closes #131): inline `wildmenu()` and
  `prompt_line()` functions in `render.rs` deleted; replaced with calls to
  `hjkl_prompt_tui::render_wildmenu` and `hjkl_prompt_tui::build_prompt_line`.
  `render.rs` now imports `hjkl-prompt-tui`; a new `prompt_theme()` helper maps
  `UiTheme` → `PromptTheme` so user-configured palette is forwarded. Wiring path
  A chosen (additive, no `hjkl-prompt` API change).
- **`apps/hjkl` info popup titles** (closes #139): `ExEffect::Info` handler in
  `ex_dispatch.rs` now infers per-command titles from content prefix —
  `"--- Registers ---"` → `"registers"`, `"--- Marks ---"` → `"marks"`,
  `"--- Jump list ---"` → `"jumps"`, `"--- Change list ---"` → `"changes"`,
  fallback `"info"`. No `ExEffect` API change (content-prefix approach). 4 call
  sites.
- **`apps/hjkl` SplitDir safety** (closes #140): `split_rect`, `draw_separator`,
  and `render_layout` in `render.rs`, and `update_matching` in `window.rs` —
  `unreachable!()` wildcard arms replaced with named-variant arms + explicit
  `_ => panic!(...)` so future `SplitDir` variants fail loudly at runtime.
- **`hjkl-markdown`**: dead `let code_span = false;` removed; both emission
  sites inline `false` directly. Closes #131 (dead code).
- **`hjkl-app` 0.4.8 → 0.4.9** (courtesy bump, always-bump policy #136).

### Fixed

- **CHANGELOG `[0.21.16]` link def** was `releases/tag/v0.21.16`; corrected to
  `compare/v0.21.15...v0.21.16`.
- **`apps/hjkl/src/app/tests/lsp.rs`** `hover_response_sets_info_popup`:
  `assert_eq!(popup.kind, ContentKind::Markdown)` assertion added.

## [0.21.18] - 2026-05-18

### Added

- **`hjkl-holler` 0.1.0** — pure data + ring-buffer notification bus (closes
  #20). Public surface: `Severity` (`#[non_exhaustive]` — `Info`, `Warn`,
  `Error`, `default_ttl()`, `label()`); `Holler` (`#[non_exhaustive]`, fields:
  `id`, `ts`, `severity`, `body`, `ttl`, `count`, `dismissed`; methods:
  `is_expired()`, `is_fading()`, `display_body()` with `×N` count badge);
  `HollerBus` (`#[non_exhaustive]`, `DEFAULT_HISTORY_CAP = 200`, `new()`,
  `Default`, `push()` with duplicate-collapse on consecutive same-body/severity,
  `info()` / `warn()` / `error()` convenience wrappers with 2 s / 4 s / 6 s TTL,
  `active(now)`, `history()`, `dismiss(id)`, `clear_active()`, `last_body()`,
  `last_body_or_empty()`). 21 unit tests + 2 doctests.
- **`hjkl-holler-tui` 0.1.0** — ratatui toast renderer (closes #20). Public
  surface: `HollerLayout` (`#[non_exhaustive]`, `max_width: u16`,
  `max_visible: usize`, `margin: (u16, u16)`, `new()`, `Default` → 48 / 5 /
  (1,1)); `pub fn render_active(frame, area, bus, layout, now)` — floating
  bordered stack newest-on-top in the top-right corner, severity-coloured
  borders (catppuccin palette), `Modifier::DIM` fade in the last 500 ms before
  expiry. 11 unit tests.

### Changed

- **`apps/hjkl`**: all `App::status_message: Option<String>` sites (~300
  references across `mod.rs`, `event_loop.rs`, `lsp_glue.rs`, `window.rs`,
  `buffer_ops.rs`, `mappings_dispatch.rs`, `prompt.rs`, `engine_actions.rs`,
  `syntax_glue.rs`, `picker_glue.rs`, `ex_dispatch.rs`, `ex_host_cmds.rs`,
  `render.rs`) migrated to `App::bus: HollerBus`. All status writes replaced
  with `bus.info()` / `bus.warn()` / `bus.error()`. Toast stack rendered via
  `render_active()` as a floating overlay — no bottom-line message slot.
- **`:notifications`** (alias `:notif`, min-prefix 5) ex command added: dumps
  notification history newest-first as `[-HH:MM:SS] [SEVERITY] body` in an info
  popup.

## [0.21.17] - 2026-05-18

### Added

- **`hjkl-layout` 0.1.0** — renderer-agnostic window/split layout machinery
  extracted from `apps/hjkl/src/app/window.rs` (closes #140). Public surface:
  `LayoutRect { x, y, w, h: u16 }` (`#[non_exhaustive]`, `new`, `Default`);
  `SplitDir` (`#[non_exhaustive]` — `Horizontal`, `Vertical`); `LayoutTree`
  (`Leaf(WindowId)` | `Split { dir, ratio, a, b, last_rect }`, methods:
  `leaves`, `next_leaf`, `prev_leaf`, `contains`, `replace_leaf`,
  `neighbor_below/above/left/right`, `enclosing_split_mut`, `equalize_all`,
  `for_each_ancestor`, `swap_with_sibling`, `remove_leaf`); `Window`
  (`#[non_exhaustive]`, `new`, `with_scroll`, `Default`); `Tab`
  (`#[non_exhaustive]`, `new`, `Default`); `WindowId` type alias. 47 tests (30+
  unit + 2 doctests).

### Changed

- **`apps/hjkl/src/app/window.rs`** shrunk to thin wrapper (~310 LOC):
  re-exports
  `hjkl_layout::{LayoutRect, LayoutTree, SplitDir, Tab, Window, WindowId}`, adds
  `rect_to_layout` / `layout_to_rect` conversion helpers, and retains all `App`
  window-action `impl` methods.
- `ratatui::layout::Rect` field accesses (`width`/`height`) replaced with
  `LayoutRect` fields (`w`/`h`) throughout `apps/hjkl`.

## [0.21.16] - 2026-05-18

### Added

- **`hjkl-prompt` 0.1.0** — renderer-agnostic ex/search prompt bar state machine
  (closes #131 partially). Public surface: `PromptKind` (`#[non_exhaustive]` —
  `Command`, `SearchForward`, `SearchBackward`, `prefix_char()`);
  `CommandCompletion` (`#[non_exhaustive]`,
  `original`/`candidates`/`selected`/`replace_range`, `new` constructor);
  `PromptOutcome` (`#[non_exhaustive]` — `Continue`, `Submit`, `Cancel`,
  `TabForward`, `TabBackward`, `HistoryPrev`, `HistoryNext`, `Dirty`);
  `PromptState` (`#[non_exhaustive]`, `new`/`with_prefill`/`default`, `text()`,
  `cursor()`, `vim_mode()`, `cursor_shape()`, `handle_input()`,
  `apply_history_nav()`, `advance_completion()`); `pub fn push_history`,
  `pub fn history_prev`, `pub fn history_next`. 24 unit tests + 4 doctests.
- **`hjkl-prompt-tui` 0.1.0** — ratatui adapter for `hjkl-prompt` (closes #131
  partially). Public surface: `PromptTheme` (`#[non_exhaustive]`, insert/normal
  bg, text, tag fg, wildmenu bg/fg/selection, `Default` dark fallback, `new`
  constructor); `pub fn render_prompt_line(frame, prompt, theme, area)` —
  renders `:{text}` / `/{text}` / `?{text}` with `[I]`/`[N]` tag and positions
  terminal cursor at insertion point;
  `pub fn render_wildmenu(frame, prompt, theme, area)` — renders candidate strip
  with `+N more` overflow;
  `pub fn build_prompt_line(content, mode, theme, width) -> Line`;
  `pub fn has_wildmenu(prompt) -> bool`;
  `pub fn is_search_prompt( prompt) -> bool`. 9 unit tests + 1 doctest.

### Changed

- **`apps/hjkl/src/app/prompt.rs`** `CommandCompletion` struct and
  `push_history` logic moved to `hjkl-prompt`; prompt.rs now re-exports
  `hjkl_prompt::CommandCompletion` and delegates `history_prev`/`history_next`
  to the new crate. Event-loop wiring and App method bodies remain in-tree.
- **`apps/hjkl/src/app/mod.rs`** `command_completion` field type changed from
  `crate::app::prompt::CommandCompletion` to `hjkl_prompt::CommandCompletion`.
  `App::push_history` delegates to `hjkl_prompt::push_history`.
- **`hjkl-app` 0.4.7 → 0.4.8** (courtesy bump, always-bump policy #136).
- GUI (`hjkl-prompt-gui`) deferred to #147.

## [0.21.15] - 2026-05-18

### Added

- **`hjkl-info-popup` 0.1.0** — renderer-agnostic info popup data model (closes
  #139 partially). Public surface: `InfoPopup` (`#[non_exhaustive]`,
  title/content/kind/position/dismissed, `new` + `markdown` constructors,
  `From<String>`, `lines()`, `line_count()`); `ContentKind` (`Plain` default,
  `Markdown` for K-key LSP path); `InfoPosition` (`Centered` default);
  `InfoViewport`; `InfoRect`; `pub fn geometry(popup, viewport) -> InfoRect` —
  80%×60% centered rect. 11 unit tests + 2 doctests.
- **`hjkl-info-popup-tui` 0.1.0** — ratatui adapter for `hjkl-info-popup`
  (closes #139). Public surface: `InfoPopupTheme` (`#[non_exhaustive]`,
  border/md slots, `Default` dark fallback);
  `pub fn render(frame, popup, theme, buf_area)` — clears area, draws bordered
  box, renders plain `Paragraph` for `Plain` content or `hjkl-markdown-tui`
  styled lines for `Markdown` content. 6 unit tests + 1 doctest.

### Changed

- **`apps/hjkl/src/render.rs`** `info_popup_overlay` body replaced with a thin
  delegate to `hjkl_info_popup_tui::render` (≤10 LOC shim vs. ~17 LOC before).
- **`apps/hjkl/src/app/mod.rs`** `info_popup: Option<String>` →
  `Option<InfoPopup>`. Title is now popup-specific (e.g. `" marks "`,
  `" registers "`, `" hover "`) rather than always `" info "`.
- **`apps/hjkl/src/app/lsp_glue.rs`** K-key hover path (`handle_hover_response`)
  migrated to `InfoPopup::markdown("hover", raw_markdown)` — raw LSP markdown
  passed directly instead of through `strip_markdown`. Mouse hover path
  (`handle_hover_at_mouse_response`) similarly migrated to raw markdown via
  `extract_hover_markdown`. `strip_markdown` / `extract_hover_text` dropped.
- **`hjkl-app` 0.4.6 → 0.4.7** (courtesy bump, always-bump policy #136).

## [0.21.14] - 2026-05-18

### Added

- **`hjkl-markdown` 0.1.0** — renderer-agnostic markdown event stream (closes
  #15 partially). Public surface: `pub enum Event` (`#[non_exhaustive]` — Text,
  Heading, CodeBlock, Rule, ListItem, Blank, Link);
  `pub fn parse(src) -> Vec<Event>` (CommonMark via pulldown-cmark 0.13);
  `MdThemeSlots` with `dark()` default. 10 unit tests.
- **`hjkl-markdown-tui` 0.1.0** — ratatui adapter for `hjkl-markdown` (closes
  #15). Public surface: `pub struct MdTheme` (`#[non_exhaustive]`, 10 color
  slots, `Default` dark fallback);
  `pub fn to_lines(events, theme, width) -> Vec<Line>` — line-wrapping markdown
  event renderer with naive word-wrap. 5 unit tests + 1 doctest.
- **`hjkl-hover` 0.1.0** — renderer-agnostic hover popup state and geometry
  (closes #126 partially). Public surface: `HoverState` (`#[non_exhaustive]`
  content/anchor/dismissed/scroll/expiry/max_width/max_height); `HoverAnchor`
  (`#[non_exhaustive]`, col/row, `new`); `HoverViewport`; `HoverRect`;
  `pub fn position(state, viewport) -> HoverRect` — above/below overflow flip,
  right-edge clamp; `scroll_lines(n: isize)`; `is_expired(Instant) -> bool`. 8
  unit tests + 1 doctest.
- **`hjkl-hover-tui` 0.1.0** — ratatui adapter for `hjkl-hover` (closes #126).
  Public surface: `HoverTheme` (`#[non_exhaustive]`, border/background/md slots,
  `Default` dark fallback); `pub fn render(frame, state, theme, viewport)` —
  delegates position math to `hjkl_hover::position`, parses content via
  `hjkl_markdown::parse`, converts to lines via `hjkl_markdown_tui::to_lines`,
  paints bordered `Paragraph` with scroll offset. 4 unit tests.

### Changed

- **`apps/hjkl/src/hover_popup.rs`** replaced with shim: re-exports
  `HoverState as HoverPopup` from `hjkl-hover`; free-function
  `new(content, (col, row))` preserves the old tuple-anchor call site. ~145 LOC
  moved to crates; shim is ~18 LOC.
- **`apps/hjkl/src/render.rs`** hover section delegates to
  `hjkl_hover_tui::render` with `HoverTheme` built from `app.theme.ui`
  (border_active → border, panel_bg → background). Pixel-identical output.
- **`apps/hjkl/src/app/lsp_glue.rs`** `strip_markdown` / `extract_hover_text`
  remain in-tree (used by `handle_hover_response` → `info_popup`). Mouse hover
  path (`handle_hover_at_mouse_response`) now creates `HoverState` via the shim.
  TODO(#15) comment updated.
- **`hjkl-app` 0.4.5 → 0.4.6** (courtesy bump, always-bump policy).

## [0.21.13] - 2026-05-18

### Added

- **`hjkl-completion-tui` 0.1.0** — ratatui adapter for `hjkl-app` completion
  (closes #129). Extracts `apps/hjkl/src/render.rs::completion_popup` (~150 LOC)
  into `crates/hjkl-completion-tui/`. Public surface:
  `pub struct CompletionTheme` (`#[non_exhaustive]`,
  border/selected_bg/normal_fg/detail_fg color slots; `CompletionTheme::new` and
  `Default`);
  `pub fn popup(frame, completion, theme, anchor: Rect, viewport: Rect)` —
  positions the popup below/above the cursor anchor with overflow-flip handling,
  paints the list. 6 new tests (smoke theme, to_rcolor, empty no-op, 2
  positioning).

### Changed

- **`apps/hjkl/src/render.rs::completion_popup`** reduced to a thin wrapper:
  computes gutter width and cursor-cell `Rect`, builds `CompletionTheme` from
  `app.theme.ui`, delegates to `hjkl_completion_tui::popup`. ~115 LOC moved to
  crate. Pixel-identical output.
- **`hjkl-app` 0.4.4 → 0.4.5** (courtesy bump, always-bump policy #136).

## [0.21.12] - 2026-05-18

### Added

- **`hjkl-which-key` 0.1.0** — renderer-agnostic which-key popup model (closes
  #128). Extracts `apps/hjkl/src/which_key.rs` (~207 LOC) into
  `crates/hjkl-which-key/`. Public surface: `Entry` (`#[non_exhaustive]`),
  `entries_for` (generic over action type `A`, mode `M: Into<hjkl_vim::Mode>`),
  `should_show`, `format_key`, `truncate_desc`, `layout` / `PopupLayout`. Pure
  geometry — no painting. `gui` feature stub declared for future floem adapter.
  23 new tests (11 in crate, 4 in tui adapter, 8 in app integration).
- **`hjkl-which-key-tui` 0.1.0** — ratatui adapter for `hjkl-which-key` (closes
  #128). `pub fn render(frame, layout, header, theme, buf_area)` paints the
  popup. `PopupTheme` (`#[non_exhaustive]`) holds `hjkl_theme::Color` border +
  desc slots; `PopupTheme::new(border, desc)` constructs without struct literal.

### Changed

- **`#134` cosmetic** — `:nmap`/`:noremap` bindings now show
  `"→ <rhs notation>"` (truncated at 40 chars with `…`) instead of the opaque
  `"user runtime map"` in the which-key popup. `truncate_desc` lives in
  `hjkl-which-key`.
- **`apps/hjkl/src/which_key.rs`** reduced to a thin shim:
  `pub use hjkl_which_key::*;`. All existing `crate::which_key::*` call sites
  compile unchanged. No behaviour delta.
- **`apps/hjkl/src/render.rs::which_key_popup`** delegates painting to
  `hjkl_which_key_tui::render`. Layout math removed from render.rs.
  Pixel-identical output.
- **`hjkl-app` 0.4.3 → 0.4.4** (courtesy bump, always-bump policy #136).

## [0.21.11] - 2026-05-18

### Added

- **`hjkl-theme` 0.2.0** — `loader` module with free-function API: `parse_toml`,
  `load_from_path`, `resolve_palette_refs`, `default_theme()`. Bundled
  `themes/default.toml` dark palette (closes #143).

### Changed

- **`hjkl-bonsai` → 0.7.5**, **`hjkl-theme-tui` → 0.1.1**: updated `hjkl-theme`
  dep pin from `0.1` to `0.2`.
- **`hjkl-statusline` → 0.2.1**: updated `hjkl-theme` dep pin from `0.1` to
  `0.2`.

## [0.21.10] - 2026-05-18

### Added

- **`hjkl-keymap-crossterm` 0.1.0** — new renderer-adapter crate (closes #142).
  Extracts `apps/hjkl/src/keymap_translate.rs` (~179 LOC) into
  `crates/hjkl-keymap-crossterm/`. Exposes `pub fn from_crossterm` and
  `pub fn to_crossterm` for crossterm ↔ `hjkl_keymap::KeyEvent` translation. All
  7 unit tests relocated into the crate. Mirrors the `-tui`/`-gui` naming rule
  from #100. Future `hjkl-keymap-floem` will follow when `apps/hjkl-gui` needs
  key input.

### Changed

- **`apps/hjkl/src/keymap_translate.rs`** reduced to a thin shim:
  `pub use hjkl_keymap_crossterm::*;`. Existing
  `crate::keymap_translate::from_crossterm` and `to_crossterm` call sites
  compile unchanged. No behaviour delta — pure relocation.
- **`hjkl-app` 0.4.2 → 0.4.3** (courtesy bump, always-bump policy #136).

## [0.21.9] - 2026-05-18

### Added

- **`hjkl-splash` 0.3.0** — new `pub mod start_screen` folds the start-screen
  surface into the splash crate (closes #130). `StartScreen` owns version
  string, key hints, recent files (reserved), and a `StartScreenTheme` palette.
  `StartScreen::build(version)` constructs the hjkl-preset default. Behind the
  `ratatui` feature, `start_screen::render(frame, area, screen)` paints the
  surface — painting logic moved from `apps/hjkl/src/start_screen.rs` (~100
  LOC). A `gui` feature stub (`render_gui` no-op) is declared for future floem
  integration.

### Changed

- **`apps/hjkl/src/start_screen.rs`** reduced to a thin shim: re-exports
  `hjkl_splash::start_screen::StartScreen`; `new_with_theme` converts the app
  `UiTheme` RGB values into `StartScreenTheme`; `render` delegates to the crate.
  No behaviour change — pixel-identical output.
- **`hjkl-app` 0.4.1 → 0.4.2** (courtesy bump, always-bump policy #136).
- **`hjkl-splash` pin** in `apps/hjkl` bumped `"0.2"` → `"0.3"`.

## [0.21.8] - 2026-05-17

### Fixed

- **Fix 1 (HIGH parity)** — restore recording-register full-line takeover.
  `build_normal_status_bar` no longer pushes a `recording_segment` badge; the
  `build_status_line` early-return guard (vim bottom-line takeover) is the sole
  path. `rec_chars` reservation removed from the filename-width calculation. Two
  new tests in `apps/hjkl/src/app/tests/render_recording.rs` assert the
  full-width banner fires when active and falls through to the normal bar when
  stopped.
- **Fix 2 (HIGH API freeze)** — `#[non_exhaustive]` on `Segment`, `ModeKind`,
  and `StatusTheme` in `hjkl-statusline`. Future variants / colour slots can
  land without breaking exhaustive matches or struct literals in downstream
  code. `StatusTheme` gains a `Default` impl (Nord palette greys) and a
  `StatusTheme::new(bg, fg)` constructor. `hjkl-statusline-tui`'s
  `segment_to_span` match gets a `_ =>` fallback arm.
- **Fix 3 (CRITICAL theme consistency)** — `hjkl-statusline` drops its parallel
  `Color`, `Modifiers`, and `Style` type definitions. It now re-exports
  `hjkl_theme::{Color, Modifiers, StyleSpec as Style}`. Builder methods (`fg`,
  `bg`, `bold`, `italic`, `default_style`) live on a new `StyleExt` trait.
  `hjkl-statusline-tui` imports `StyleExt`; `apps/hjkl/src/render.rs` imports
  `StyleExt` and constructs `StatusTheme` via mutation (required by
  `#[non_exhaustive]` outside the defining crate). `hjkl-theme::Color` carries
  an alpha channel (`a: u8`); TUI renderers already ignore it. `hjkl-statusline`
  bumped 0.1.0 → **0.2.0** (breaking: type aliases changed, struct literals
  blocked). `hjkl-statusline-tui` bumped 0.1.0 → **0.2.0** (consumer re-pin).
  `apps/hjkl` repinned to `"0.2"` for both.

### Changed

- **`hjkl-app` 0.4.0 → 0.4.1** (courtesy bump per always-bump policy, #136). No
  source changes; version advance keeps it from going stale as upstream
  statusline crates roll.

## [0.21.7] - 2026-05-17

### Fixed

- v0.21.6 `cargo publish -p hjkl --locked` failed because `hjkl-app@0.3.0` on
  crates.io pins `hjkl-vim = "0.22"`, but `apps/hjkl` (and `apps/hjkl-gui`)
  bumped their direct pin to `hjkl-vim = "0.23"` for the #64 which-key work.
  Cargo refused to resolve two major versions of `hjkl-vim` in the same dep
  graph — `OperatorKind` flowed through both copies, surfacing as an `E0308`
  type-mismatch wall. Fix: bump `hjkl-app` 0.3.0 → 0.4.0 (MINOR — drops the old
  `hjkl-vim 0.22` pin in favour of `0.23`) and repin both apps to
  `hjkl-app = "0.4"`. Per "no retag of shipped versions", v0.21.6's git tag
  stays as the source-of-truth release and v0.21.7 carries the publish-side fix.

### Added

- **`hjkl-statusline` + `hjkl-statusline-tui` v0.1.0** (#12 partial —
  normal-mode row only): extracted the normal-mode status bar from
  `apps/hjkl/src/render.rs` into two new publishable crates. `hjkl-statusline`
  owns the agnostic data model (`Bar`, `Segment`, `Style`, `StatusTheme`,
  `ModeKind`, segment builder fns, `truncate_filename`). `hjkl-statusline-tui`
  adapts it to ratatui (`to_line`, `render`, `to_ratatui_style`).
  `apps/hjkl/src/render.rs::build_status_line`'s normal-mode branch now
  delegates to `build_normal_status_bar` which calls
  `hjkl_statusline_tui::to_line`. 15 new unit tests across both crates. Variants
  1–5 (command prompt, search prompt, perf overlay, status message,
  indent-flash) remain in `build_status_line` pending future extractions (#131).
  CI `publish_if_missing` cascades both before `hjkl-app` / `hjkl`.

## [0.21.6] - 2026-05-17

### Added

- **Which-key shows vim FSM built-in bindings** (#64): `entries_for` in
  `apps/hjkl/src/which_key.rs` now merges `hjkl_vim::descriptors::children_for`
  (engine FSM) with app keymap entries. App entries win on conflict so `:nmap`
  shadows builtins. Covers Normal root (83 keys), g-prefix (19), z-prefix (11),
  and operator-pending (24). Visual root also exposed.
- Bumped `hjkl-vim` submodule `0.22.0` → `0.23.0` (adds `descriptors` module).

## [0.21.5] - 2026-05-17

### Fixed

- **which-key popup didn't render in TUI** — chord-timeout in `App::with_config`
  and `App::new` was set from `which_key.delay_ms` directly. The same event-loop
  iteration that activated the popup also hit the chord-timeout deadline,
  calling `resolve_chord_timeout` which immediately cleared `which_key_active`
  before any frame rendered. Chord-timeout now strictly exceeds the which-key
  delay (`+500ms` headroom in `with_config`, `1000ms` matching vim's
  `timeoutlen` default in `App::new`). Follow-up #133 tracks exposing the value
  as an explicit config field.
- v0.21.4 `cargo publish -p hjkl --locked` failed with
  `unresolved import 'crate::keymap_actions::CmdLineWindowKind'` and
  `no variant 'OpenCmdLineWindow' found`. The #37 work (`q:` / `q/` / `q?`)
  added these types to `hjkl-app::keymap_actions`, but hjkl-app on crates.io was
  still 0.2.0 (last published in v0.21.3 cascade). Bumped hjkl-app 0.2.0 → 0.3.0
  (additive API = MINOR); apps/hjkl + apps/hjkl-gui repinned to `"0.3"`.

## [0.21.4] - 2026-05-17

### Changed

- Bumped hjkl-ex submodule 0.4.0 → 0.4.1 (58 unit tests backfilled in
  `builtins.rs`, no public API change, #107 cascade).

## [0.21.3] - 2026-05-17

### Fixed

- `cargo publish -p hjkl --locked` on v0.21.2 failed with a wall of
  `the trait bound 'hjkl_app::picker_sources::DiagSource: PickerLogic' is not satisfied`
  errors. Root cause: hjkl-app's deps were flipped in v0.21.0 (engine 0.11,
  picker 0.9) but hjkl-app's own version stayed at 0.1.0. Publishing hjkl pulls
  hjkl-app@0.1.0 from crates.io, which pins old hjkl-picker 0.7 — its
  `PickerLogic` trait signature doesn't match what the current source expects.
  Bumped hjkl-app 0.1.0 → 0.2.0 (MINOR — breaking dep change). The publish job
  now ships hjkl-app@0.2.0 first, then hjkl@0.21.3 resolves it correctly.
- Updated `apps/hjkl` and `apps/hjkl-gui` pins `hjkl-app = "0.1"` → `"0.2"` to
  track the bump.

## [0.21.2] - 2026-05-17

### Fixed

- `cargo fmt --all --check` rejected two files left unformatted after the #100
  `hjkl_ratatui` → `hjkl_editor_tui` source substitution lengthened
  import/format lines past rustfmt's wrap point
  (`apps/hjkl/examples/form_demo.rs`, `apps/hjkl/src/render.rs:1374`). Applied
  formatter; v0.21.1's tag-CI rustfmt check failed before the publish step could
  run.

## [0.21.1] - 2026-05-17

### Fixed

- v0.21.0's Cargo.lock still listed `hjkl` and `hjkl-gui` workspace members at
  `version = "0.20.4"` because `cargo generate-lockfile` doesn't refresh
  workspace-member version entries — only `cargo build` does (memory
  `feedback_bctp_lockfile_regen.md`). `cargo publish -p hjkl --locked` rejected
  the mismatch. v0.21.0 was tagged but `hjkl@0.21.0` never reached crates.io;
  v0.21.1 restores publish.

## [0.21.0] - 2026-05-17

### Changed

- **#99 — default-feature flip:** `hjkl-engine` default features changed from
  `["serde", "crossterm", "ratatui"]` to `["serde"]`. `hjkl-vim` default
  features changed from `["crossterm"]` to `[]`. `hjkl-editor` default features
  changed from `["serde", "crossterm", "ratatui"]` to `["serde"]`. Consumers
  that relied on defaults must now opt in explicitly with
  `features = ["crossterm", "ratatui"]` (or whichever subset they need).
- **#100 — rename `hjkl-ratatui` → `hjkl-editor-tui`:** New crate
  `hjkl-editor-tui` published at v0.1.0 with identical implementation. The old
  `hjkl-ratatui` 0.7.0 is now a thin `pub use hjkl_editor_tui::*` shim — all old
  code keeps working unchanged, new code should use `hjkl-editor-tui`.
  `apps/hjkl` migrated to `hjkl-editor-tui` directly.
- Submodule cascade for engine 0.11 semver-incompatibility:
  - `hjkl-engine` 0.10 → 0.11
  - `hjkl-vim` 0.21 → 0.22
  - `hjkl-ex` 0.3 → 0.4
  - `hjkl-form` 0.5 → 0.6
  - `hjkl-picker` 0.8 → 0.9
  - `hjkl-editor` 0.7 → 0.8
  - `hjkl-ratatui` 0.5 → 0.7 (0.6 last impl, 0.7 shim)
  - `hjkl-picker-tui` 0.3 → 0.4
  - `hjkl-editor-gui` 0.1 → 0.2 (in-tree)
- `hjkl-compat-oracle` engine/vim pins updated to 0.11/0.22 with explicit
  features.
- `apps/hjkl` engine dep now
  `default-features = false, features = ["crossterm", "ratatui"]` — no longer
  relying on engine defaults.

## [0.20.4] - 2026-05-17

### Fixed

- `publish-crates` workflow step exited 1 silently right after the
  `hjkl-app already on crates.io` skip on the v0.20.3 tag. The
  `grep | head | cut` pipeline that extracts the manifest version returned exit
  1 (no match) for `apps/hjkl/Cargo.toml`'s `version.workspace = true` line.
  With `set -e -o pipefail`, the pipeline failure killed the script before the
  workspace-version fallback ran. Added `|| true` to both grep pipelines so the
  empty result threads through to the existing fallback. v0.20.3 was tagged but
  `hjkl@0.20.3` never reached crates.io; v0.20.4 restores publish.

## [0.20.3] - 2026-05-17

### Fixed

- v0.20.2 tag-CI failed on `test windows-latest` because the un-ignored
  hjkl-anvil tests #110 brought in were symlink-dependent (the runtime itself
  returns `UnsupportedPlatform` on non-unix). hjkl-anvil 0.2.4 gates the 8
  affected tests behind `#[cfg(unix)]` and `#[allow(dead_code)]`s the helpers
  that become unused on Windows. v0.20.2 was tagged but the cross-build /
  publish-crates jobs were skipped, so the umbrella never shipped to crates.io /
  AUR / brew / apk; v0.20.3 restores the release.

### Changed

- `hjkl-anvil` bumped 0.2.1 → 0.2.4 — submodule pointer updated.

## [0.20.2] - 2026-05-17

### Changed

- `.config/nextest.toml` — added `anvil-env` test group (`max-threads = 1`) for
  `package(hjkl-anvil)`. Serializes the 18 env-mutating XDG path-resolution and
  full-pipeline tests (previously `#[ignore]`'d, unreachable from CI) so they
  run automatically under `cargo nextest run --workspace`. The 16
  previously-ignored tests across `store`, `installer`, and the integration
  suite now have real CI coverage.
- `hjkl-anvil` bumped to 0.2.1 — submodule pointer updated.

## [0.20.1] - 2026-05-17

### Fixed

- `cargo publish -p hjkl --locked` failed on v0.19.3 and v0.20.0 (silently —
  neither tag's `hjkl` crate reached crates.io) with
  `no matching package named hjkl-app found`. Since Stage 1a of #125,
  `apps/hjkl` declares `hjkl-app = "0.1"` but `hjkl-app` was `publish = false`,
  and cargo requires path-deps in published crates to also be resolvable from
  crates.io. Flipped `hjkl-app` to publishable and updated the `publish-crates`
  job to upload `hjkl-app` first (with index-wait poll) before publishing
  `hjkl`.

### Added

- `hjkl-app` 0.1.0 now publishes to crates.io (was in-tree-only). README added
  with the standard badge set.

## [0.20.0] - 2026-05-17

### Added

- `Editor::lnum_width() -> u16` (in hjkl-engine 0.10.0) — single source of truth
  for line-number gutter width. Consumers can call this instead of recomputing
  `max(digits+1, numberwidth)` locally (#96).

### Changed

- `apps/hjkl::render` and `apps/hjkl::app::mouse` now call `editor.lnum_width()`
  instead of maintaining a local copy of the gutter-width formula. Eliminates
  the off-by-one risk when `numberwidth` settings diverge.
- Cascade bump: hjkl-engine `0.9` → `0.10`, hjkl-vim `0.20` → `0.21`,
  hjkl-editor `0.6` → `0.7`, hjkl-ex `0.2` → `0.3`, hjkl-form `0.4` → `0.5`,
  hjkl-ratatui `0.4` → `0.5`, hjkl-picker `0.7` → `0.8`, hjkl-picker-tui `0.2` →
  `0.3`.

## [0.19.3] - 2026-05-17

### Changed

- Bumped pinned hjkl-ex 0.2.0 → 0.2.1 (dropped unused thiserror dep) and
  hjkl-lsp 0.1.0 → 0.1.1 (dropped unused lsp-types dep). No public API changes.

### Removed

- Dropped four unused crate-level deps from `apps/hjkl` flagged by
  `cargo machete`: crossbeam-channel, ec4rs, hjkl-theme, ignore. All four became
  dead after Stages 1a-1c (#125) moved their consumers (config, editorconfig,
  lang, git_worker, picker_sources, picker_git) into hjkl-app.

## [0.19.2] - 2026-05-16

### Changed

- Promoted hjkl-mangler to its own kryptic-sh/hjkl-mangler submodule. Mangler
  0.1.0 is now live on crates.io, unblocking the publish-crates CI job that
  failed on v0.19.1 with "no matching package named hjkl-mangler found". Local
  builds continue to use the workspace [patch.crates-io] table; downstream
  crates.io consumers now get the published version.

## [0.19.1] - 2026-05-16

### Fixed

- Submodule pointers at v0.19.0 were stale (pre-BCTP SHAs), causing
  `cargo zigbuild --locked` to reject the lockfile on all 7 release-build
  targets. Advances pointers to the matching tagged submodule commits
  (hjkl-engine v0.9.1, hjkl-buffer v0.7.0, hjkl-keymap v0.3.0, hjkl-vim v0.20.0,
  hjkl-editor v0.6.0, hjkl-ex v0.2.0, hjkl-form v0.4.0, hjkl-picker v0.7.0,
  hjkl-ratatui v0.4.0, hjkl-picker-tui v0.2.0). No user-visible behavior change
  beyond unblocking the release pipeline.

## [0.19.0] - 2026-05-16

### Added

- `hjkl-mangler` crate: `Formatter` trait + 8 built-in formatter impls (rustfmt,
  prettier, gofmt, black, isort, clang-format, stylua, shfmt). Detects Rust
  edition from `Cargo.toml` (handles `edition.workspace` shorthand). Probes tool
  availability up-front with exit-success check; falls back gracefully when
  missing.
- `FormatWorker`: async per-buffer format dispatch in `hjkl-mangler`; wired into
  `apps/hjkl` as non-blocking `=` with deferred tool install. Drain
  stdout/stderr in threads so large files no longer deadlock.
- Range-only formatting for `=` operator (#119): native per-formatter range
  flags, `similar` dep dropped.
- `=` operator wired to `hjkl-mangler` with dumb-algo fallback; formatter result
  is undoable (routed through `set_content_undoable`).
- Keymap predicate intercepts (#120): phases 2–4 migrate static inline
  intercepts and predicate-gated intercepts into the keymap trie;
  `handle_keypress` / `handle_mouse` extracted from `run()`.
- `App::dispatch_action` split by domain (#121): 780-LOC monolith broken into
  focused domain handlers.
- Mouse Phase 1: click-to-cursor, drag-select, double/triple-click (#114).
- `ContextMenu` widget + render integration; right-click dispatch (clipboard +
  tab menus); `menu_copy` / `menu_cut` / `menu_paste` action wrappers.
- Left-click on tab bar switches to that tab; click on buffer line switches
  buffer.
- `mouse::hit_test_zone` (Code / Gutter / TabBar / None); unified top-bar layout
  (tab bar + buffer line merged into single top row).
- `Zone::StatusLine` / `SplitBorder` / `PickerRow` + hit-test routing;
  right-click menus for status line, split borders, and picker overlay.
- Middle-click zone dispatch (paste / close tab / close buffer). Ctrl+click
  goto-def, Shift+click extend visual, middle-click primary-paste.
- Drag-resize splits + double-click equalize; `hit_test_border` + `BorderHit`
  type.
- `:set mouse=<flags>` per-mode toggle.
- `HoverPopup` widget; LSP hover popup on mouse-rest (500ms); LSP items wired
  into right-click Code menu; `App::active_has_lsp` helper for menu
  enable/disable.
- Per-slot top/bottom/viewport span caches with merged install; pre-cache top +
  bottom spans per buffer (bottom deferred for snappy startup).
- `ParseKind` tag on parse requests + results for syntax worker.
- Syntax over-provision: viewport requests 3× height for ahead-of-scroll spans;
  pre-warm parse for all open buffers; per-slot span cache for instant buffer
  switch.
- Per-row dirty tracking to eliminate white-text flash after edits.
- Brief flash highlight on `=` / auto-indent operator; auto-indent flash blinks
  twice at 2× speed.
- `<leader>/` regression test (grep picker flow).
- Visual-selection empty-line placeholders: `hjkl-buffer` paints selection
  placeholder on empty rows, spanning full block width.
- Web: add pikr to siblings nav + sitemap.

### Changed

- Dep bumps (L0–L2 cascade): `hjkl-engine` 0.8 → 0.9, `hjkl-buffer` 0.6 → 0.7,
  `hjkl-keymap` 0.2 → 0.3, `hjkl-vim` 0.19.0 → 0.20, `hjkl-editor` 0.5 → 0.6,
  `hjkl-ex` 0.1 → 0.2, `hjkl-form` 0.3 → 0.4, `hjkl-picker` 0.6 → 0.7,
  `hjkl-ratatui` 0.3 → 0.4, `hjkl-picker-tui` 0.1 → 0.2. Applied to `apps/hjkl`,
  `apps/hjkl-gui`, `crates/hjkl-editor-gui`, and `crates/hjkl-compat-oracle`.
- Tab labels: dropped `[]` brackets, use flanking-space style.
- `mangler`: `probe_tool` returns diagnostic `Err` instead of `bool`.
- Refactor: `hjkl_buffer::over_provisioned_range` used for syntax pre-warm.

### Fixed

- `/ ?` intercept now gated on no-pending-chord so `<leader>/` works correctly.
- Formatter: `BrokenPipe` tolerated when writing to formatter stdin; edition
  parser ignores `edition.workspace` shorthand.
- Auto-indent flash clamped to text area; armed at submit time (not
  format-install); single 75ms flash (reverted blink-twice).
- Mouse `cell_to_doc` + render updated for new sign-column layout; off-by-1
  click corrected when sign column is active.
- `screen_rect` includes top bar height; menu hover→item mapping wrong near
  screen edges (fixed + tests).
- Right-click now moves cursor to click position. Hover popup suppressed while
  overlays are open; dismissed on any mouse click. Backspace on empty `:/?/`
  prompt dismisses it.
- Cold-open: `Top` + `Bottom` submitted alongside `Viewport`; cold-detect by
  destination region (not viewport-only); adaptive big-jump wait — 500ms cold,
  40ms warm; briefly blocks on big-jump parse to avoid `gg/G` white-text flash.
- Worker tree corruption and stale top/bottom cache re-submit fixed; stale
  dirty-gen caches skipped for delete+undo cycles; stale row-count caches
  skipped in span merge.
- Picker hit-test determinism + Windows-aware grep parser (fixed in tests).
- `is_tool_installed` treats spawn-success as installed (mangler probe fix).
- Clippy: silence `saturating-sub`, `collapsible-if`, `cloned-ref-to-slice`
  warnings; CI all-features clippy/test unblocked on main.

## [0.18.2] - 2026-05-16

### Added

- Markdown fenced/indented code blocks now render with a subtle tinted
  background. `@markup.raw.block` carries `bg = $codeblock_bg` (a panel tint two
  steps off the editor bg), and the layered resolver in `hjkl-buffer` 0.6.1 lets
  that bg shine through under the injected language's `fg`-only spans. Same
  visual as vim/Helix's layered hi-group model.

### Changed

- `crates/hjkl-buffer` bumped 0.6.0 → 0.6.1. `resolve_span_style` now layers
  every overlapping span (broadest-first `Style::patch` merge) instead of
  picking the single narrowest one. Narrower spans still win for every field
  they set; unset fields fall through to broader spans. This is the contract
  that makes the code-block tint possible without bloating every injected
  language's captures.

### Tests

- `markup_raw_block_has_bg_for_code_block_tint` pins the requirement that the
  theme entry sets `bg` (without which the new layered resolver has nothing to
  layer).

## [0.18.1] - 2026-05-16

### Fixed

- Markdown syntax highlighting in `apps/hjkl`. Three independent bugs combined
  to leave most of a markdown buffer unstyled:
  - `apps/hjkl/themes/syntax-dark.toml` lacked any `@markup.*` keys. The
    capture-fallback chain truncates by dot
    (`@markup.heading.1 → @markup.heading → @markup → None`), so block-level
    constructs (`##` headers, lists, quotes, code spans, links) all rendered
    unstyled. Added `@markup.heading{,.1..6}`, `@markup.italic`,
    `@markup.strong`, `@markup.strikethrough`, `@markup.raw{,.block}`,
    `@markup.link{,.label,.url}`, `@markup.list{,.checked,.unchecked}`,
    `@markup.quote`, and `@label` (code-fence language tag).
  - `hjkl-bonsai` 0.7.3 — injection walker now honours
    `(#set! injection.language "lang")` directive form. Previously only the
    `@injection.language` capture form was consumed, silently dropping every
    directive-only injection. tree-sitter-markdown's `injections.scm` uses the
    directive form for `(inline) → markdown_inline`, `(html_block) → html`,
    `(minus_metadata) → yaml`, `(plus_metadata) → toml`, so paragraph-inline
    content (`*italic*`, `**bold**`, `` `code` ``, links) never injected
    `markdown_inline` and rendered without highlighting.
  - `hjkl-bonsai` 0.7.4 — highlight-span order on identical byte ranges now
    favours the more-specific capture. tree-sitter-markdown emits both
    `@markup.link` and `@markup.link.url` on a `(link_destination)` node with
    the same byte range; `hjkl-buffer`'s resolver keeps the first equal-length
    match, so source-order in the .scm file decided the winner. Markdown's
    `@markup.link` pattern is declared first, so URLs in `[label](url)` rendered
    the same colour as `[label]` (label colour), losing vim/Helix's dim-URL
    distinction. New `sort_by_start_then_depth` orders ties by capture depth
    descending.

### Changed

- `crates/hjkl-anvil` bumped: `reqwest 0.12 → 0.13` (with feature rename
  `rustls-tls` → `rustls + rustls-native-certs`), `sha2 0.10 → 0.11`,
  `zip 2 → 8`.
- `crates/hjkl-theme` bumped: `toml 0.8 → 1.1`.

### Tests

- `syntax_theme_covers_markdown_markup_captures` pins the 24 captures
  tree-sitter-markdown emits; `markup_heading_renders_bold` and
  `markup_strikethrough_uses_strikethrough_modifier` pin the visible-bug shape
  from 0.18.0.
- `hjkl-bonsai`: four `sort_*` unit tests pin the depth-aware sort semantics,
  and `markdown_inline_injection_directive_form_fires_resolver` (+ scoped
  variant) pin the directive-form injection regression. The pre-existing
  `child_cache_avoids_repeated_parses` test now calls `parse_initial` explicitly
  to seed the parent tree (it was silently failing before).

## [0.18.0] - 2026-05-16

### Added

- `hjkl-ex` crate (Phase 1–8 arc): new dedicated ex-command registry,
  dispatcher, and completion crate. Major public surface: `Registry<H>` /
  `HostRegistry<Ctx>` for registering built-in and host-provided commands;
  `try_dispatch` for FSM-agnostic command execution; `complete` + `complete_arg`
  for Tab-completion; `parse_range` for Vim-style line-range parsing;
  `all_setting_names` for `:set <Tab>` expansion. Host/FSM boundary is fully
  decoupled — the crate carries no ratatui or event-loop dependency.
- `HostCmd<Ctx>` trait + `HostRegistry<Ctx>` foundation (Phase 4a): allows host
  applications to register arbitrary context-typed commands without touching
  `hjkl-ex` internals.
- Wildmenu Tab-completion rendering and per-command argument completers (Phase
  5a/5b/6): `:e`, `:w`, `:set`, `:colorscheme`, and custom host commands all
  surface completions through the unified `complete_arg` protocol.
- `% / # / <cword>` filename expansion and Vim filename modifiers (`%:h`, `%:t`,
  `%:r`, `%:e`, `%:p`) in ex command arguments (Phase 7).
- `apps/hjkl-gui` binary: floem-based GUI host backed by the new
  `hjkl-editor-gui` adapter crate (commit 7b6f971). Follows the `<project>-gui`
  binary naming convention.
- `:set background=dark|light` surfaces in `:set <Tab>` completion via
  `all_setting_names()`; both `"background"` and `"bg"` aliases included.
- Tests: `:write[!]` disk-state guard (2 cases), `:set background=` inline
  dispatch (3 cases), `hjkl-gui` smoke tests (2 cases).
- `docs/README.md` and `LICENSE` added to `hjkl-ex`, `hjkl-picker-tui`,
  `hjkl-theme`, and `hjkl-theme-tui` submodule repos.

### Changed

- Submodule extractions: `hjkl-ex` 0.1.0, `hjkl-picker-tui` 0.1.0, `hjkl-theme`
  0.1.0, and `hjkl-theme-tui` 0.1.0 now live at their own `kryptic-sh/*`
  repositories with independent CI and release pipelines; in-tree paths remain
  via `[patch.crates-io]`.
- `hjkl-picker` bumped 0.5 → 0.6 with headless API migration (commit c37a948);
  picker callers updated to the new interface.
- `apps/hjkl-gui`: `hjkl-editor` dep bumped `"0.4"` → `"0.5"` (stale
  post-0.5.0).
- `apps/hjkl`: `hjkl-engine` dep range loosened `"0.7.0"` → `"0.7"` so the
  patched 0.7.1 is in-range without manifest-side re-pin.
- `.gitmodules`: `crates/hjkl-lsp` URL changed from SSH (`git@github.com:`) to
  HTTPS (`https://github.com/kryptic-sh/hjkl-lsp.git`) for anonymous-clone
  compatibility.
- `hjkl-compat-oracle`: removed redundant `path =` segments from `hjkl-buffer`
  and `hjkl-engine` deps; umbrella `[patch.crates-io]` resolves them.
- `hjkl-ex`: `all_setting_names()` now includes `"background"` and `"bg"` so
  `:set <Tab>` surfaces the option.

### Fixed

- Sync-helper bypass paths and stale dead-code from FSM extraction closed
  (commit 3a1d7ec): every engine-mutating arm in `event_loop.rs` now routes
  through `sync_after_engine_mutation`.
- H1 (submodule context): `hjkl-clipboard` 0.5.4 — INCR chunk read timeout
  raised from 10 s to 30 s, fixing clipboard hangs on slow Wayland compositors.
- H2 (submodule context): `hjkl-engine` 0.7.1 — `editor.rs` `Key` import gated
  behind the `crossterm` feature flag; previously compiled unconditionally and
  broke headless builds.

### Removed

- Legacy `hjkl-editor::ex` module deleted (Phase 8b); all ex-command routing now
  lives in `hjkl-ex`. Consumers migrated in Phase 8a before deletion.
- Dead code: `AppAction::AnvilUninstall` and `AppAction::AnvilUpdate` enum
  variants, `AnvilState::installed_version` and `AnvilState::registry_version`
  fields, and the `backtab_event()` helper function (commit dc618e5).

## [0.17.1] - 2026-05-15

### Fixed

- Restore syntax highlighting in apps/hjkl. The 0.17.0 theme migration wired
  `theme.style(span.capture())` against a TOML keyed by `@keyword` /
  `@function.method`, but tree-sitter's `query.capture_names()` returns the bare
  names (`keyword`, `function.method`) — every span silently dropped to the
  `None` arm and rendered with no style. Lifted to bonsai 0.7.2 which prepends
  `@` on lookup so both bare and `@`-prefixed inputs resolve.
- Cursor shape now flips to `SteadyBar` when entering Insert via `i` / `I` / `a`
  / `A` / `o` / `O` and back to `SteadyBlock` on `Esc` / `Ctrl-O`. The
  app-keymap dispatch path for insert-entry actions and `dispatch_insert_key`
  both bypassed `hjkl_vim::handle_key` (the canonical site for
  `emit_cursor_shape_if_changed`); now called explicitly from
  `sync_after_engine_mutation` and after `dispatch_insert_key`.
- bonsai 0.7.1: `lua-match?` / `not-lua-match?` registered as built-in
  predicates. Previously the unknown predicate was emitted permissively,
  producing false-positive highlight spans on Lua files. Lua patterns are
  translated to Rust `regex` via POSIX character classes (`%a` → `[[:alpha:]]`,
  …); unsupported constructs (`%b`) fall back to permissive.

## [0.17.0] - 2026-05-15

### Added

- New backend-agnostic theme stack: `hjkl-theme` 0.1.0 (TOML parse, palette
  interning, capture fallback chain) + `hjkl-theme-tui` 0.1.0 (`ToRatatui`
  extension trait converting `Color`/`Modifiers`/`StyleSpec` into ratatui
  types). Both crates published to crates.io and patched locally via
  `[patch.crates-io]`.

### Changed

- `hjkl-bonsai` bumped 0.6 → 0.7 (breaking): removed bespoke `Style` struct and
  `Style::to_ratatui`; now re-exports `hjkl_theme::StyleSpec`. `Theme::style`
  returns `Option<&hjkl_theme::StyleSpec>`. Bonsai's bundled
  `themes/default-{dark,light}.toml` were rewritten in the new schema.
- `apps/hjkl` migrated onto the new theme stack: bumped its `hjkl-bonsai` dep,
  added `hjkl-theme` + `hjkl-theme-tui` deps, swapped `.to_ratatui()` call sites
  onto `hjkl_theme_tui::ToRatatui`, and converted
  `apps/hjkl/themes/syntax-dark.toml` to the new schema (`@`-prefixed TS capture
  names, `[palette]` interning table, `modifiers = [...]` arrays).
- `apps/hjkl/themes/ui-dark.toml` + the `UiTheme` parser left untouched —
  bespoke chrome/mode/cursor/picker/form tables don't map cleanly to
  `hjkl_theme::UiStyles` yet; deferred.

### Migration

User-customized `apps/hjkl/themes/syntax-dark.toml` overrides need to be
converted to the new schema. See the in-tree `apps/hjkl/themes/syntax-dark.toml`
or `crates/hjkl-bonsai/themes/default-dark.toml` for a reference layout.

## [0.16.0] - 2026-05-15

### Changed

Phase 6.6 of kryptic-sh/hjkl#72 — full vim FSM extraction. The engine is now a
pure controller and the FSM lives in `hjkl-vim`. apps/hjkl drives the FSM
through `hjkl_vim::handle_key` / `hjkl_vim::dispatch_input` instead of the
deleted `Editor::handle_key` / `Editor::step_input` / `hjkl_engine::step`.

Dep bumps (breaking dep removals + new API in hjkl-vim):

- `hjkl-engine` 0.6.9 → 0.7.0 (Phase 6.6 FSM deletion — breaking)
- `hjkl-vim` 0.18.1 → 0.19.0 (canonical FSM entry points)
- `hjkl-editor` 0.4.6 → 0.4.7 (dep bumps only)
- `hjkl-form` 0.3.6 → 0.3.7 (internal FSM driver swapped)
- `hjkl-ratatui` 0.3.6 → 0.3.7 (dep bumps only)

apps/hjkl internal callsites of `editor.handle_key` migrated to
`hjkl_vim::handle_key` (apps/hjkl/src/app/event_loop.rs +
apps/hjkl/src/app/mod.rs production paths, apps/hjkl/src/app/tests.rs's 60 test
callsites). No external UX change.

## [0.15.3] - 2026-05-14

### Changed

- Bumped `hjkl-engine` 0.6.8 → 0.6.9. Picks up Phase 6.1 of kryptic-sh/hjkl#72 —
  16 new public `Editor::*` insert-mode controller primitives (`insert_char`,
  `insert_newline`, `insert_tab`, `insert_backspace`, `insert_delete`,
  `insert_arrow`, `insert_home` / `insert_end`, `insert_pageup` /
  `insert_pagedown`, `insert_ctrl_w` / `_u` / `_h` / `_t` / `_d`,
  `insert_ctrl_o_arm` / `insert_ctrl_r_arm`, `insert_paste_register`,
  `leave_insert_to_normal`). The FSM still works (internal `step_insert` now
  delegates to the new primitives); release is purely additive. Engine 0.6.9
  ships 31 new unit tests covering the new surface (722 engine tests total).

## [0.15.2] - 2026-05-14

### Fixed

- Gate `pty_harness::at_colon` e2e module off on macOS. macOS pty timing causes
  `at_colon_repeats_last_goto_line` to see `:10\r` as literal Insert-mode text
  (`screen` shows `:10j ... [I]`); the other 12 e2e tests pass on macOS,
  including the `:100` regression in `render_sync.rs`. The `@:` feature is fully
  covered by unit tests in `apps/hjkl/src/app/tests.rs`. v0.15.1 tag was created
  but never reached publish steps because of this flake; v0.15.2 is the first
  0.15.x line that actually ships.

## [0.15.1] - 2026-05-14

### Fixed

- Gate `apps/hjkl/tests/e2e.rs::pty_harness` on `cfg(unix)`. ConPTY +
  portable-pty on Windows behaves differently enough that the harness assertions
  don't hold (cursor reads return 0,0; rendered rows don't carry the expected
  gutter format). Windows CI now green; unix coverage unchanged (13 e2e tests
  still run on linux/macOS). v0.15.0 tag was created but never reached publish
  steps; v0.15.1 is the first 0.15.x line that ships.

## [0.15.0] - 2026-05-14

### Added

- **Phase 5a — marks chord lift (#71).** `m<x>`, `'<x>`, `` `<x> `` chords route
  through the hjkl-vim reducer (`PendingState::SetMark` /
  `PendingState::GotoMarkLine` / `PendingState::GotoMarkChar`) and dispatch
  `EngineCmd::SetMark` / `GotoMarkLine` / `GotoMarkChar` to
  `Editor::set_mark_at_cursor` / `goto_mark_line` / `goto_mark_char` instead of
  re-entering the engine FSM.
- **Phase 5b — macro chord lift (#71).** `q<x>` and `@<x>` chords route through
  `PendingState::RecordMacroTarget` and
  `PendingState::PlayMacroTarget { count }`. `EngineCmd::StartMacroRecord` /
  `EngineCmd::PlayMacro` dispatch to `Editor::start_macro_record` /
  `Editor::play_macro`. Macro replay flows back through `route_chord_key` (Phase
  6 prereq).
- **Phase 5c — dot-repeat (`.`) lift (#71).** `AppAction::DotRepeat { count }`
  resolves the count prefix and calls `Editor::replay_last_change`. Engine FSM
  `.` arm preserved for macro-replay defensive coverage.
- **Phase 5d — `@:` last-ex repeat (#71).** App captures every executed `:`
  command into `last_ex_command: Option<String>`. The `PlayMacroTarget` reducer
  accepts `':'` and the host calls `replay_last_ex`. Normal-mode `:` interceptor
  now guarded with `pending_state.is_none()` so `@:` does not open the command
  prompt.
- **Phase 5e — count + register-routing audit + bug fixes (#71).** New unit
  - e2e tests cover `5dd`, `"a5dd`, `5"add`, `"a5x`, `3@a`, `5.`, `"add`×2,
    `"+y`, `"_d`. Fixed `5"add` count-drop (removed `pending_count.reset()` from
    `BeginPendingSelectRegister` arm) and `"a5dd` premature digit flush
    (count-prefix block now skipped when `pending_state.is_some()`).
- **Phase 3e/f/g — additional motions via hjkl-vim keymap path (#69).** `;` `,`
  `%` `H` `M` `L` `<C-d>` `<C-u>` `<C-f>` `<C-b>` route through
  `AppAction::Motion { kind, count }` → `Editor::apply_motion` instead of the
  engine FSM. Engine FSM arms preserved for macro-replay coverage.
- **Phase 4e — visual-mode operators dispatch via keymap (#70).**
  `AppAction::VisualOp { op, count }` resolves the active selection range and
  calls a range-mutation primitive; engine exits visual mode. Visual, VisualLine
  and VisualBlock all covered (VisualBlock falls back to the engine FSM for
  shapes that need `apply_block_operator`). Named-register routing (`"a y` then
  visual `p`) now honored on the visual path.
- **e2e PTY+vt100 test harness** (`apps/hjkl/tests/pty_harness/`). `portable-pty
  - vt100`driven`TerminalSession`lets tests assert against actual rendered terminal output. Initial coverage:`render_sync.rs`(5 historical bugs:`:100`, `gg`, visual `gg`, `30j`, `G`), `at_colon.rs`(Phase 5d), and`register_count.rs`(round-trip paste,`5"add`, `3@a`).
- `route_chord_key` central dispatcher (apps/hjkl/src/app/mod.rs) — single entry
  point for all chord routing. Engine-pending bypass + reducer step in one
  helper. Macro replay now flows through this path so future Phase 6 engine
  slimming doesn't regress macro behavior.
- `flush_pending_count_to_engine` and `sync_after_engine_mutation` helpers in
  event_loop.rs for centralised count-flush and viewport-sync semantics.

### Changed

- **`cursorline` default flipped from `false` → `true`** (carried via
  `hjkl-engine` 0.6.8). Existing `:set nocursorline` config still toggles off.
- `pending_count: String` migrated to `hjkl_vim::CountAccumulator` —
  digit-prefix buffer with vim's digit-0-vs-LineStart quirk and overflow
  saturation built in.
- Bumped `hjkl-engine` 0.6.7 → 0.6.8 (Phase 5 controllers + cursorline default
  flip).
- Bumped `hjkl-vim` 0.18.0 → 0.18.1 (Phase 5b/5d macro reducer states +
  `EngineCmd` variants).
- Bumped `hjkl-editor` 0.4.5 → 0.4.6 (engine 0.6.8 dep + cursorline snapshot +
  `:100` engine-layer regression test).

### Fixed

- **`:100<Enter>` cursor-stuck regression** (commit 23cb46b). Engine cursor
  moved to line 100 but the window cursor cache stayed at the old row.
  `dispatch_ex` now calls `sync_viewport_from_editor` after every `ex::run`
  regardless of effect — cursor-only ex commands no longer skip the sync.
- **Render-sync class fixes** (commits 219de02, 0694b42, 1cead4e, 4414170,
  b8d0459): keymap-Match dispatch, non-Normal keymap Match, keymap-dispatched
  motion viewport scroll, pending-state Outcome arms, and VisualBlock
  `block_vcol` all now route through `sync_after_engine_mutation`.
- **`gg` / visual-`gg` routing-order fixes** (commits 0621944, 76f3f55):
  pending_state reducer lifted out of Normal-mode gate; non-Normal trie dispatch
  gated on `pending_state.is_none()`.

## [0.14.11] - 2026-05-13

### Changed

- Phase 3d of the vim FSM extraction (#62, tracking #69) — `G` now routes
  through the `hjkl-vim` keymap path. `AppAction::Motion { kind, count }`
  dispatches to `Editor::apply_motion` instead of re-entering the engine FSM.
  Engine FSM arm for `G` is kept for macro-replay coverage. `gg` stays on the
  G-chord path from Phase 2b-ii.
- Bumped `hjkl-vim` 0.14 → 0.15 — adds `MotionKind::GotoLine`. Count 1 means
  last line, count > 1 means goto line N.
- Bumped `hjkl-engine` 0.6.3 → 0.6.4 — `apply_motion` routes `GotoLine` to
  `Motion::FileBottom`.

### Added

- `G` entry in the motion binding loop across Normal / Visual / VisualLine /
  VisualBlock. `'G'` already in `could_start_chord` (no event_loop change
  needed).

## [0.14.10] - 2026-05-13

### Changed

- Phase 3c of the vim FSM extraction (#62, tracking #69) — line-anchored motions
  (`0` / `^` / `$`, plus `<Home>` / `<End>` aliases) now route through the
  `hjkl-vim` keymap path. The host dispatches
  `AppAction::Motion { kind, count }` and calls `Editor::apply_motion` instead
  of re-entering the engine FSM. Engine FSM arms remain for macro-replay
  coverage. `g_` (last non-blank) stays on the G-chord path from Phase 2b-ii.
  `|` (column N) is not yet implemented in the engine and is deferred.
- Bumped `hjkl-vim` 0.13 → 0.14 — adds 3 new `MotionKind` variants (`LineStart`
  / `FirstNonBlank` / `LineEnd`).
- Bumped `hjkl-engine` 0.6.2 → 0.6.3 — `apply_motion` now handles the 3 new
  variants via `Motion::LineStart` / `Motion::FirstNonBlank` /
  `Motion::LineEnd`.

### Added

- 5 entries in the motion binding loop (`apps/hjkl/src/app/mod.rs`) for `0`,
  `<Home>`, `^`, `$`, `<End>` across Normal / Visual / VisualLine / VisualBlock.
  `^` and `$` added to `could_start_chord` matches in `event_loop.rs` (digit `0`
  already handled by the digit-buffer split). `<Home>` / `<End>` added to the
  non-Char branch alongside `<BS>`.

## [0.14.9] - 2026-05-13

### Changed

- Phase 3b of the vim FSM extraction (#62, tracking #69) — word motions (`w` /
  `W` / `b` / `B` / `e` / `E`) now route through the `hjkl-vim` keymap path. The
  host dispatches `AppAction::Motion { kind, count }` and calls
  `Editor::apply_motion` instead of re-entering the engine FSM. Engine FSM arms
  for these keys are intentionally kept so the macro- replay path (`@<reg>`)
  continues to resolve them. `ge` / `gE` were already covered by the G-chord
  migration in Phase 2b-ii.
- Bumped `hjkl-vim` 0.12 → 0.13 — adds 6 word-motion `MotionKind` variants
  (`WordForward` / `BigWordForward` / `WordBackward` / `BigWordBackward` /
  `WordEnd` / `BigWordEnd`).
- Bumped `hjkl-engine` 0.6.1 → 0.6.2 — `apply_motion` now handles the 6 new
  word-motion variants by reusing the same `execute_motion` primitives
  (`Motion::WordFwd` / `BigWordFwd` / `WordBack` / `BigWordBack` / `WordEnd` /
  `BigWordEnd`) the FSM arms call.

### Added

- 6 entries in the Phase 3a motion binding loop (`apps/hjkl/src/app/mod.rs`) for
  `w` / `W` / `b` / `B` / `e` / `E` across Normal / Visual / VisualLine /
  VisualBlock. Count-prefix buffering (`5w` etc.) preserved by extending the
  `could_start_chord` matches in `event_loop.rs`.

## [0.14.8] - 2026-05-13

### Changed

- Phase 3a of the vim FSM extraction (#62, tracking #69) — char + line motions
  (`h` / `l` / `<BS>` / `<Space>` / `j` / `k` / `+` / `-`) now route through the
  `hjkl-vim` keymap path. The host dispatches
  `AppAction::Motion { kind, count }` and calls `Editor::apply_motion` instead
  of re-entering the engine FSM. Engine FSM arms for these keys are
  intentionally kept so the macro- replay path (`@<reg>`) continues to resolve
  them.
- Bumped `hjkl-vim` 0.11 → 0.12 — adds `MotionKind` enum carrying the keymap-
  layer motion identity (`CharLeft` / `CharRight` / `LineDown` / `LineUp` /
  `FirstNonBlankDown` / `FirstNonBlankUp`). Marked `#[non_exhaustive]` so Phases
  3b–3g can extend without breaking downstream match arms.
- Bumped `hjkl-engine` 0.6.0 → 0.6.1 — adds `Editor::apply_motion(kind, count)`
  controller method backed by an internal `apply_motion_kind` helper that reuses
  the same motion primitives as the FSM. Cursor, sticky column, scroll, and sync
  semantics are identical between the two paths.

### Added

- New `AppAction::Motion { kind: MotionKind, count: u32 }` variant in
  `apps/hjkl/src/keymap_actions.rs`. Bound across Normal / Visual / VisualLine /
  VisualBlock for the 8 Phase 3a motions. Count-prefix buffering (`5j` etc.) is
  preserved by extending the `could_start_chord` set in `event_loop.rs` to cover
  the newly keymap-bound keys.

## [0.14.7] - 2026-05-13

### Fixed

- `:nmap` cyclic-recursion guard (`MAX_DEPTH` in `dispatch_action`'s `Replay`
  arm) lowered from 1024 to 128 to fit comfortably within macOS's 512 KB
  per-thread stack default. Previously
  `cyclic_recursive_map_bails_without_stack_overflow` intermittently SIGABRT'd
  on macOS CI before the depth guard fired. 128 is still far beyond any
  realistic nested-map depth.

### Changed

- Phase 2c of the vim FSM extraction (#62) — bare op-pending (`d` / `y` / `c` /
  `>` / `<`), `OpFind` / `OpTextObj` / `OpG` sub-states, chord-init case-ops
  (`gu` / `gU` / `g~` / `gq`), and register prefix (`"<reg>`) all migrated from
  the engine FSM to the `hjkl-vim` reducer. The reducer now owns the entire
  op-pending state machine for user keystrokes; engine `Pending::Op*` arms
  remain only for macro-replay defensive coverage.
- Bumped `hjkl-vim` 0.5 → 0.11 across chunks 2c-i..vi:
  - 0.6 — `OperatorKind` + `PendingState::AfterOp` (chunk 2c-i).
  - 0.7 — `PendingState::OpFind` + `EngineCmd::ApplyOpFind` (chunk 2c-ii).
  - 0.8 — `PendingState::OpTextObj` + `EngineCmd::ApplyOpTextObj` (chunk
    2c-iii).
  - 0.9 — `PendingState::OpG` + `EngineCmd::ApplyOpG` (chunk 2c-iv).
  - 0.10 — `OperatorKind::{Uppercase, Lowercase, ToggleCase, Reflow}` for
    chord-init case-op bridge (chunk 2c-v).
  - 0.11 — `PendingState::SelectRegister` + `EngineCmd::SetPendingRegister`
    (chunk 2c-vi).
- Bumped `hjkl-engine` 0.5.8 → 0.6.0 across chunks 2c-i..vii:
  - 0.5.12..0.5.17 — `Editor::apply_op_motion` / `apply_op_double` /
    `apply_op_find` / `apply_op_text_obj` / `apply_op_g` /
    `set_pending_register` controller entry points.
  - 0.6.0 (breaking) — removed transitional `enter_op_text_obj` / `enter_op_g` /
    `enter_op_find` controllers, now superseded by the matching `apply_op_*`
    methods (chunk 2c-vii).
- Bumped `hjkl-form` 0.3.5 → 0.3.6, `hjkl-editor` 0.4.4 → 0.4.5, and
  `hjkl-ratatui` 0.3.5 → 0.3.6 — caret bumps to `hjkl-engine ^0.6`, no API
  changes.

## [0.14.6] - 2026-05-13

### Changed

- Phase 2b of the vim FSM extraction (#62): the three bare second-char chord
  families — find (`f`/`F`/`t`/`T`), g-prefix, and z-prefix — are now driven by
  the `hjkl-vim` reducer instead of the engine FSM. User-visible behavior
  unchanged; chord dispatch now lives in the host's pending-state loop and calls
  controller methods on `Editor` (`find_char`, `after_g`, `after_z`). Engine
  `Pending::Find` / `Pending::G` / `Pending::Z` arms remain intact for the
  operator-pending variants (`OpFind`, `OpG`) which migrate in chunk 2c.
- Bumped `hjkl-vim` to 0.5 across the three chunks:
  - 0.3 — `PendingState::Find` + `EngineCmd::FindChar` (chunk 2b-i).
  - 0.4 — `PendingState::AfterG` + `EngineCmd::AfterGChord` (chunk 2b-ii).
  - 0.5 — `PendingState::AfterZ` + `EngineCmd::AfterZChord` (chunk 2b-iii).
- Bumped `hjkl-engine` to 0.5.11 across the three chunks:
  - 0.5.9 — `Editor::find_char` controller entry.
  - 0.5.10 — `Editor::after_g` controller entry.
  - 0.5.11 — `Editor::after_z` controller entry.

### Added

- New `AppAction` variants `BeginPendingFind`, `BeginPendingAfterG`,
  `BeginPendingAfterZ` route the app's `f`/`g`/`z` bindings (Normal + Visual)
  through the hjkl-vim reducer.
- Phase 2b-ii pulled 7 overlapping `g*` prefix entries (`gt`, `gd`, `gD`, `gr`,
  `gi`, `gy`, …) out of the static keymap trie to resolve the bare-`g`
  ambiguity; their dispatch now flows through the `AfterGChord` arm. No
  user-visible change.

## [0.14.5] - 2026-05-13

### Fixed

- Bumped `hjkl-engine` to 0.5.8: 5 vim-compat divergences fixed and their oracle
  cases re-promoted from `known_divergences.toml` to active tier-2 corpus files
  (kryptic-sh/hjkl#83):
  - Dot mark `'.`/`` `. `` records change-start position, not post-insert cursor
    (`mark_dot_jump_to_last_edit`).
  - `100G` clamps to last content row on trailing-newline buffers
    (`count_100G_clamps_to_last_line`).
  - `gi` moves to last-insert position and enters insert mode
    (`gi_resume_last_insert`).
  - Visual-block `c<text><Esc>` cursor lands on last inserted char
    (`visual_block_jl_c_change_block`).
  - `"_` (black-hole) register discards deletes without touching unnamed
    register (`register_blackhole_d`).
- `hjkl-engine` 0.5.7 fix: `` `< `` / `` `> `` (and `'<` / `'>` linewise
  variants) now resolve correctly through `handle_goto_mark`. The marks were set
  by the visual-exit hook (0.5.3) but the goto-mark dispatcher didn't list `<` /
  `>` in its target match, so `` `< `` silently no-op'd. Surfaced by the oracle
  tier-2 marks corpus.

### Changed

- Bumped `hjkl-anvil` dependency to `0.2` (TOFU checksum verification). GitHub
  tool installs (rust-analyzer, lua-language-server) no longer fail on
  placeholder zero SHAs; the first download's hash is recorded and enforced on
  subsequent installs.

### Added

- Phase 2 chunk 2a (#68): `hjkl-vim` now exports `PendingState`, `Outcome`,
  `Key`, `step`, and `EngineCmd` (v0.2.0). The `r<x>` replace chord is
  intercepted by the app keymap trie (`BeginPendingReplace`) and driven by
  `hjkl_vim::step` — the engine's `Pending::Replace` arm is now unreachable from
  the umbrella but left intact. `Editor::replace_char_at` promoted to public in
  `hjkl-engine` v0.5.5 as the controller entry point.
- `hjkl-engine` v0.5.6 (#81): vim special marks `[` / `]` (last yank / change /
  paste bounds). After any `y<motion>`, `d<motion>`, `c<motion>`, `p`, or `P`,
  `` `[ `` jumps to the first affected char and `` `] `` to the last. Mode-aware
  positioning (linewise, charwise, blockwise). Enables the `` `[v`] `` re-select
  idiom. Backtick mark jumps now work in Visual modes.
- Oracle (`hjkl-compat-oracle`) tier-2 corpus expansion (#82): 5 → 16 test
  functions, ~120 cases covering marks, visual mode, dot-repeat, search,
  substitute, macros, case/join, paragraph/word, text objects, visual block, and
  registers/increment/insert shortcuts. Acts as a regression net for the
  upcoming `#62` FSM extraction and `#80` ex-extraction refactors. Tier-1
  backfilled with 13 basic cases (`x`, `X`, `r`, `~`, `J`, `p`, `P`, `W`, `B`,
  `E`, `F`, `T`, `;`, `,`).

## [0.14.4] - 2026-05-12

### Changed

- Phase 1 of the vim FSM extraction (#62) — new `hjkl-vim` crate at v0.1.0
  holding the `Mode` discriminator (`Normal`, `Insert`, `Visual`, `VisualLine`,
  `VisualBlock`, `OpPending`, `CommandLine`). Pure plumbing — behavior
  identical. `apps/hjkl::keymap::HjklMode` is now a
  `pub use hjkl_vim::Mode as HjklMode` alias so existing imports keep resolving.
  Subsequent phases (#68–#72) will move the FSM itself out of `hjkl-engine` into
  this crate.
- `hjkl-vim` lives in a standalone submodule repo
  ([kryptic-sh/hjkl-vim](https://github.com/kryptic-sh/hjkl-vim)) with the
  canonical `ci.yml` + tag-driven publish pipeline.

## [0.14.3] - 2026-05-12

### Added

- Visual-mode `:` now prefills the command prompt with `'<,'>` and exits visual
  so range-aware ex commands apply to the selection. Pair this with hjkl-engine
  0.5.3's `<` / `>` mark hook for `:'<,'>sort`, `:'<,'>s/old/new/`,
  `:'<,'>w >>file`, `:'<,'>!fmt`, `:'<,'>d`, and any other `:[range]` command on
  `V<motion>:` / `v<motion>:` / `<C-v>:` flows.
- `App::open_command_prompt_with(prefill)` helper.

### Changed

- Local `HjklMode` enum in `apps/hjkl/src/app/keymap.rs` replaces the
  hjkl-keymap concrete `Mode` enum. `Keymap<AppAction>` field types are now
  `Keymap<AppAction, HjklMode>`. Moves to `hjkl-vim` when issue #62 lands.
  Required by hjkl-keymap 0.2.0's generic-Mode refactor.

### Performance

- Markdown preview no longer hitches on the file picker. Two fixes:
  - `App::spans_for_viewport` clips the highlighter's `byte_range` to the
    viewport (with 50-row slack for off-screen injection context). Parent parse
    still runs over full bytes (no partial-parse API for a fresh tree) but
    injection scan + child highlights stay bounded.
  - hjkl-bonsai 0.6.2 caches child highlighters in `Highlighter` keyed by
    `(lang, content_range)` with content-hash drift detection.
- Fast buffer switching latency cut substantially. Three fixes:
  - `SyntaxLayer::preview_render` now reuses a cached `Highlighter` per grammar
    (calling `reset()` between calls) instead of constructing a fresh one via
    `Highlighter::new` every switch. Skips dlopen-related setup and query
    compilation; the bonsai child-cache survives across calls.
  - Dropped the 5 ms `wait_result` in `recompute_and_install`'s viewport-only
    resubmit path. Worker spans arrive on a subsequent tick instead.
  - New `GitSignsWorker` (apps/hjkl/src/git_worker.rs) spawns a background
    thread that runs `git2::Diff` + `is_untracked` off the UI thread. Coalesce
    policy: latest-wins per `buffer_id`. `App::poll_git_signs` drains results
    each tick.

### Dependencies

- `hjkl-keymap` 0.1.1 → 0.2.0 (breaking — concrete `Mode` enum removed, replaced
  with `Mode` trait; `Keymap<A, M>` generic over mode discriminator). Plus
  `Keymap::children_all`, `Keymap::pop`, and the `timeout_resolve` pure-prefix
  fix that all landed in the 0.1.2 / 0.1.3 / 0.1.4 patches.
- `hjkl-picker` 0.5.1 → 0.5.2 (adds `PreviewHighlighter::spans_for_viewport`
  trait method).
- `hjkl-bonsai` 0.6.1 → 0.6.2 (caches child highlighters across injection
  passes).
- `hjkl-engine` 0.5.2 → 0.5.4 (visual-exit hook sets `'<` / `'>` marks with
  mode-aware position semantics: charwise tuple-ordered, linewise snaps to line
  edges, blockwise uses independent min/max corners).

## [0.14.2] - 2026-05-12

### Added

- Which-key Backspace navigation. Backspace inside an active chord pops the last
  key from the buffer, surfacing the parent level in the popup. When the last
  key is popped the popup enters sticky mode showing root-level entries until
  the user types something else. Esc dismisses and clears chord + sticky state.
- New `App::which_key_sticky` field; cleared on Esc and any non-Backspace
  keypress.

### Changed

- Which-key popup content now reads from `Keymap::children_all` (#57). The five
  static descriptor tables (`LEADER_ENTRIES`, `G_ENTRIES`,
  `BRACKET_RIGHT_ENTRIES`, `BRACKET_LEFT_ENTRIES`, `CTRL_W_ENTRIES`) and the
  `Prefix` enum are deleted. Runtime `:nmap` entries auto-surface in the popup
  with zero changes to `which_key.rs`. Sub-prefix collapse (e.g. `<leader>g`
  showing the top-level Leader table) is fixed.
- `App::active_which_key_prefix` returns the raw pending buffer
  (`Vec<KeyEvent>`) instead of collapsing to a `Prefix` enum.
- Header label derived from `Chord(pending).to_notation(leader)`; sticky
  empty-buffer state renders as `"root"`.

### Fixed

- Which-key popup no longer disappears the instant chord-timeout fires on a
  leader prefix. Pure-prefix buffers (e.g. `<leader>` alone with no terminal
  binding at that depth) now keep the buffer alive past the timeout instead of
  being drained. Requires `hjkl-keymap` 0.1.4.

### Refactor

- Retired the five dead `pending_*` prefix fields on `App` (`pending_leader`,
  `pending_git`, `pending_lsp`, `pending_buffer_motion`,
  `pending_window_motion`) — vestigial after #11 + #59 with
  `#[allow(dead_code)]`. Migrated 7+ tests from inlined event-loop logic to real
  dispatch via `drive_key`. Removed 26 vestigial `self.pending_* = false` writes
  across 11 `open_*_picker` methods. Closes #58.
- `Ambiguous` chord state now resolves to the shorter binding when `timeoutlen`
  elapses (#60). Wired `App::resolve_chord_timeout` into the event-loop
  poll-timeout branch; widened poll deadline calc to include the chord-timeout
  alongside the which-key deadline. Three regression tests: shorter-binding
  fires on timeout, longer-binding fires on fast second key, no-pending returns
  None.

### Dependencies

- `hjkl-keymap` 0.1.1 → 0.1.4. Adds `Keymap::children_all` (which-key children
  with prefix-only submenus), `Keymap::pop` (chord backspace navigation), and
  fixes `timeout_resolve` to not drain pure-prefix buffers.

## [0.14.1] - 2026-05-12

### Fixed

- SHIFT-modifier normalization at the crossterm → keymap boundary. Char events
  arriving with `+SHIFT` (kitty, foot, wezterm w/ kitty keyboard protocol) now
  match bindings registered as `ch('B')` (mods=NONE). Affects `<leader>gB`,
  `<leader>gS`, `<C-w>W`, `<C-w>T`, `<C-w>R`, `]D`, `[D`, `K`.
- Engine-pending bypass — keymap trie skips itself while the engine is in any
  multi-key pending state (`r<x>` Replace, `f<x>`/`t<x>` Find, `m<a>` SetMark,
  operator-pending, etc.), so the engine's in-flight commands complete without
  the trie eating their continuation key. Requires `hjkl-engine` 0.5.2.
- Multi-key Unbound replay forwards to engine — `gg` / `gj` / `gk` / `G` / `gE`
  / `ge` now reach the engine through the chord dispatch path (previously
  silently dropped after the trie's `g`-prefix consumed the first key).
- Cycle guard on recursive maps — `:nmap a a` and similar vertical cycles now
  bail with `E223: recursive mapping (depth limit)` instead of overflowing the
  call stack. Per-frame step counter still catches horizontal cycles.
- `<C-w><` (resize-width-narrower) binding now registers — was silently failing
  to parse due to an unescaped trailing `<`. Use `<lt>` escape.

### Changed

- Merged `RuntimeKeymaps` into the `app_keymap` trie via
  `AppAction::Replay { keys, recursive }` (#59). User `:map` / `:nmap` / `:imap`
  etc. commands now register on the same trie as built-ins.
  `user_keymap_records` side-table backs `:map` listing so built-in chords don't
  leak into user listings. Removed: `RuntimeKeymaps`, `apply_runtime_map`,
  `handle_runtime_mapped_key`, `App::runtime_keymaps`.
- Mode-generalized chord dispatch — `dispatch_keymap_in_mode` feeds the active
  vim mode so user maps in Insert / Visual / OpPending / CommandLine work.
  Terminal-mode runtime maps are silently dropped pending a Terminal variant in
  the keymap crate.
- `<C-w>>` binding switched to `<C-w><gt>` for cosmetic symmetry with
  `<C-w><lt>`. Behavior unchanged.

### Architecture

- Filed #62 (extract `hjkl-vim` grammar layer; engine becomes pure controller)
  and #63 (engine multi-selection primitives) as long-running design issues.

### Dependencies

- `hjkl-engine` 0.5.1 → 0.5.2 (adds `Editor::is_chord_pending()`)
- `hjkl-keymap` 0.1.0 → 0.1.1 (adds `<gt>` notation escape)

## [0.14.0] - 2026-05-10

### Added

- **`:Anvil install / uninstall / update / update <name>`** ex commands and the
  `:Anvil` picker for installing language servers, formatters, and linters from
  the bundled `anvil.toml` registry. `<XDG_DATA_HOME>/anvil/bin` is prepended to
  `$PATH` at startup; LSP servers resolve from anvil-installed binaries with no
  system-package install required. Closes #61.
- **`:LspInfo` anvil state** — output now includes per-server install state
  (`installed @ vX.Y.Z` / `not installed` / `not in registry`).
- **`:set` render options** — parser support for `cursorline` / `cul`,
  `cursorcolumn` / `cuc`, `signcolumn` / `scl` (yes / no / auto), `foldcolumn` /
  `fdc` (0–12), `colorcolumn` / `cc` (comma-separated columns). Closes #34.
- **which-key popup on idle** — pressing `<leader>` / `g` / `]` / `[` / `<C-w>`
  and pausing for 500 ms (configurable via `[which_key] delay_ms`) shows
  reachable bindings in a floating window. Closes #53.
- **App-level count prefix** for `[N]gt`, `[N]gT`, `[N]<C-w>+/-/</>`. Closes
  #46.
- **New crates** extracted from the monorepo and auto-published from their own
  repositories:
  - `hjkl-xdg 0.1.0` — XDG Base Directory resolver shared by bonsai, config, and
    anvil. Closes #19.
  - `hjkl-keymap 0.1.0` — chord parser + mode-scoped trie + `KeyResolve` enum,
    used by `apps/hjkl` for Normal-mode chord dispatch. Closes #11.
  - `hjkl-anvil 0.1.1` — Mason-style installer (Github / Cargo / Npm / Pip /
    GoInstall pipelines, atomic install + symlink, SHA-256 verify, async install
    pool with per-key dedupe). Closes #61.

### Fixed

- **HTML highlighting** — bonsai's `(#set! @cap …)` sanitizer now uses
  paren-balanced excision + pre-extraction so the html grammar resolves fully.
  No more plain-text fallback on `.html` files. Closes #56.
- **Windows CI test paths** — `hjkl-xdg` submodule updated to fix path handling
  on `x86_64-pc-windows-msvc`.

### Changed

- Submodule cycle bumps: `hjkl-bonsai 0.6.1`, `hjkl-engine 0.5.1`,
  `hjkl-editor 0.4.4`, `hjkl-buffer 0.6.0`, `hjkl-picker 0.5.1`,
  `hjkl-config 0.2.1`, `hjkl-form 0.3.5`, `hjkl-ratatui 0.3.5`.
- `hjkl-keymap` now backs Normal-mode chord dispatch in `apps/hjkl`; the legacy
  `pending_*` prefix fields remain `#[allow(dead_code)]` pending follow-up #58.
- `hjkl-xdg`, `hjkl-keymap`, and `hjkl-anvil` extracted to standalone submodule
  repos and wired back via `[patch.crates-io]`.

## [0.13.0] - 2026-05-09

### Added

- **Tree-sitter predicate/directive extension layer.** Bumps `hjkl-bonsai` to
  `0.6.0`, which ships a parser-agnostic dispatcher matching helix and
  nvim-treesitter behavior on top of stock tree-sitter. The
  `(#set! @capture key val)` form used by nvim-treesitter html highlights — and
  by other capture-targeted directives — is now honored end-to-end via
  pre-extraction + replay; `HighlightSpan` carries the resulting metadata
  through to consumers. Unknown predicates degrade gracefully (warn-once, match
  still emitted) instead of dropping the highlight. Resolves #56.
- **File-backed tracing output.** `tracing` now writes to
  `$XDG_DATA_HOME/hjkl/logs/hjkl.log` in addition to stderr, so post-mortem
  debugging of grammar / LSP / picker issues no longer requires re-running with
  the terminal captured.
- **Runtime keymaps.** `hjkl-config` keymap definitions are now applied at
  runtime through the app, allowing user-level keymap overrides to take effect
  without recompiling.

### Fixed

- HTML files no longer render plain text. The bonsai sanitizer's line-based
  `(#set! @cap …)` stripper used to consume a closing `)` belonging to the
  enclosing pattern when the directive's `)` shared a line with the outer
  group's `)`, leaving the resolved query unbalanced and tree-sitter erroring on
  a downstream pattern. Bonsai's paren-balanced excision (then full
  pre-extraction in 0.6.0) replaces the workaround. See bonsai#3 / bonsai#4.
- Web rails: normalized sibling rails, removed install gaps, unified install
  styles in the marketing site.
- Cleaned up clippy warnings introduced by the runtime keymap work in
  `apps/hjkl`.

## [0.12.2] - 2026-05-06

### Changed

- Extracted `hjkl-lsp` from in-tree crate to standalone submodule
  (`kryptic-sh/hjkl-lsp`), published to crates.io as `hjkl-lsp 0.1.0`, and wired
  back via `[patch.crates-io]` so `apps/hjkl` resolves it without a path dep —
  fixing `cargo publish` for the umbrella binary.

## [0.12.1] - 2026-05-07

### Changed

- Pinned `mlugg/setup-zig` to zig 0.15.1 to skip `build.zig.zon` lookup and fix
  post-step CI noise.
- Bumped `hjkl-picker` dep to `^0.5` (bonsai-agnostic `PreviewHighlighter` API).

## [0.12.0] - 2026-05-06

### Added

- **`hjkl-lsp` foundation + 5-phase Language Server Protocol integration (#38,
  #47–#51).** New in-tree `crates/hjkl-lsp` crate ships a tower-lsp-based
  client + per-language server lifecycle manager with full text-sync (open /
  change / save / close) wired onto buffer edits.
  - **Phase 2 — Diagnostics + nav (#48):** inline + signcolumn diagnostic
    rendering, severity-aware highlighting, `]d` / `[d` motions, `:LspInfo` ex
    command surfacing per-buffer attach state, server config, and capability
    diagnostics.
  - **Phase 3 — Goto + hover (#49):** `gd` / `gD` / `gi` / `gy` jump to
    definition / declaration / implementation / type-definition, `K` shows
    hover, `gr` / `:lreferences` opens references in the picker. Cursor reveals
    in viewport after every cross-buffer jump (`gd` / `]d` / `[d` / `:lfirst` /
    `:llast`).
  - **Phase 4 — Completion (#50):** triggered + manual completion popup, kind
    icons, snippet expansion, async resolve.
  - **Phase 5 — Code actions, rename, format (#51):** `<leader>ca` /
    `:LspCodeAction`, `<leader>rn` / `:LspRename`, `:LspFormat` /
    `:LspFormatRange`, with workspace-edit application across multiple files.
  - Bundled default server configs for common languages (rust-analyzer / pyright
    / typescript-language-server / clangd / gopls / lua-language-server) —
    `:LspInfo` shows which one matched the active buffer.
  - Status-line spinner while LSP requests are in flight, sharing
    `hjkl_ratatui::spinner` with the grammar-load indicator.
- **Window splits — full 4-phase rollout (#40–#43).**
  - **Phase 1 — `:sp`, `Ctrl-w j` / `Ctrl-w k`, `:close`.** Horizontal splits
    sharing the active buffer, per-window viewport state.
  - **Phase 2 — `:vsp`, `Ctrl-w h` / `l` / `w` / `W`, `Ctrl-h` / `Ctrl-l`.**
    Vertical splits + cross-direction nav.
  - **Phase 3 — Resize + equalize + maximize.** `Ctrl-w +` / `-` / `>` / `<` /
    `=` / `_` / `|`, plus `:resize` and `:vertical resize`.
  - **Phase 4 — `:only`, `Ctrl-w o`, swap, `:new`, `:vnew`, `:q` redirect.**
    `:q` on the last window in a split closes the split, not the editor.
  - **1-cell separator** painted between sibling panes (`│` / `─`, themed via
    `theme.ui.border`) so splits no longer look like a single wall of text.
  - **Per-window cursor + viewport state.** Two splits onto the same buffer
    track their own cursor row/col + scroll offset independently. Syntax-span
    computation now unions all visible viewports for the active buffer so the
    inactive split keeps its highlights when the focused split scrolls.
- **Tabs — Phase 1 + 2 (#44, #45).** `:tabnew`, `gt` / `gT`, `:tabnext` /
  `:tabprev` / `:tabclose`, plus the Phase 2 set: `:tabfirst` / `:tablast` /
  `:tabonly` / `:tabmove` / `:tabs`, `Ctrl-w T` (move current window to its own
  tab).
- **tmux-navigator handoff at split edges.** Bare `Ctrl-h` / `Ctrl-j` / `Ctrl-k`
  / `Ctrl-l` in Normal mode move between hjkl windows; if there is no neighbour
  in the requested direction and `$TMUX` is set, the keystroke falls through to
  `tmux select-pane -L/-D/-U/-R` so users can move from an edge hjkl pane
  straight into the surrounding tmux pane.
- **Mouse wheel scrolls viewport (not cursor) + `editor.mouse` toggle.** Wheel
  events now scroll the viewport with the cursor clamped inside, respecting
  `scrolloff`. New `editor.mouse = true` config field (defaulting on) and
  matching `:set mouse` / `:set nomouse` / `:set mouse!` / `:set mouse?` runtime
  ex-commands (nvim-style); the terminal mouse capture is enabled / disabled
  live without restart.
- **Picker preview is now consumer-agnostic via a `PreviewHighlighter` trait.**
  Consumers (other kryptic-sh apps) can plug in their own highlight pipeline —
  tree-sitter, LSP semantic tokens, regex, none — without `hjkl-bonsai` ever
  appearing in the picker crate. `hjkl-picker` re-exports `PreviewHighlighter`,
  `PlainPreview`, `PreviewTheme`, and a self-contained `preview_pane()`
  renderer.
- **Picker preview now highlights injected sub-languages in markdown** (e.g.
  ` ```rust ` fences inside `.md` previews) by routing through
  `Highlighter::highlight_range_with_injections` with a non-blocking grammar
  resolver: cached child grammars highlight immediately, missing ones queue an
  async load. Regression test
  `preview_spans_for_markdown_includes_rust_injection` guards the wiring.
- **Global grammar-load spinner.** The status-line `loading grammar: <name>…`
  takeover now reflects any queued grammar across the directory (not just the
  active buffer) and collapses concurrent loads to `<first> +N`.
- **`:set number` / `:set relativenumber` line-number gutter.** Aliases `nu` /
  `rnu` / `nonu` / `nornu` / `nu!` / `rnu!`. Combined `nu rnu` enables vim's
  hybrid mode: cursor row shows its absolute number, others show the offset.
  Plus `:set numberwidth=N` / `:set nuw=N` (1..=20, default 4) for minimum
  gutter width.
- **`~` tilde markers** paint at the first text column on every screen row past
  end-of-buffer, matching vim's `NonText` rendering. New `non_text` theme color
  (default `#4a5266`).
- **`+cmd` and `-c CMD` work in TUI mode** (vim/nvim parity). Previously the
  flag was gated on `--headless`; now any unknown `+token` argv is treated as an
  ex command and dispatched after buffers load but before raw mode begins.
  `hjkl +vsp file1 file2` does what you'd expect.
- **`:LspInfo`** surfaces matched server config + active-buffer attach
  diagnostics for fast triage when a server fails to attach.
- **LSP search-count** now compares in byte offsets so multibyte characters no
  longer poison the `[N/M]` indicator.

### Changed

- **All grammar compiles run on the editor pipeline.** `hjkl_bonsai`'s
  background loader pool is the single source for clone-and-compile work; the
  picker, status spinner, and active-buffer set-language path all call into the
  same async loader. Concurrent loads dedupe per language at the `hjkl-bonsai`
  source-cache layer (`Arc<Mutex<()>>` per key) so two workers no longer race on
  the shared Helix `QuerySourceCache` staging dir.
- **CI collapsed into a single `ci.yml`.** The earlier 3-stage workflow (lint →
  test → release) is replaced by one workflow gated on `tags: ['v*']`, matching
  the org-wide canonical CI pattern.
- **`hjkl-buffer` 0.4 → 0.5**, **`hjkl-editor` 0.4.1 → 0.4.1+3**,
  **`hjkl-engine` 0.3.8 → 0.3.8+3**, **`hjkl-bonsai` 0.5.0 → 0.5.3+2**,
  **`hjkl-picker` 0.4.0 → 0.4.0+5** (substring-fast-path scoring,
  `PreviewHighlighter` trait, `preview_pane`).

### Fixed

- **`/<pat><CR>` no longer double-steps past the cursor's match.** When the
  cursor was already on a match, the search post-step would jump to the next one
  instead of staying put. Forward-direction is now persisted on `+/pat` startup
  search too.
- **Viewport reveals search matches.** `/<CR>` and `+/pat` startup search now
  scroll the viewport so the matched cursor is visible, respecting `scrolloff`.
  `viewport_height` is published in `build_slot` so engine-side reveal logic has
  the height it needs.
- **Engine-handled `g` / `]` / `[` motions** sync the host viewport on the same
  tick instead of one frame later.
- **`:w` on a path whose parent directory does not exist** now does `mkdir -p`
  of the parent and writes through, instead of returning ENOENT.
- **LSP buffer paths absolutized before URI conversion.** Relative paths (e.g.
  opening hjkl with `hjkl src/main.rs`) were sent to servers as relative URIs;
  now `std::path::absolute` is applied first. Regression test added.
- **LSP attach picks up existing slots in `with_lsp`.** Buffers loaded before
  the LSP subsystem registered now attach when it comes up, with clearer status
  messages on success / skip / fail.
- **Goto picker strips `cwd` from location paths** so `references` /
  `definitions` show short relative paths instead of `/full/abs/path/file.rs`.
- **`textDocument/references` requests** now include the required `context`
  object (`includeDeclaration`); some servers rejected the request without it.
- **tmux-navigator binds fall through cleanly when there is no neighbour** (vs.
  eating the keystroke silently).
- **Cross-platform LSP tests.** `goto_definition_multi_opens_picker` and
  `goto_references_always_opens_picker` previously hardcoded `file:///tmp/a.rs`
  literals that broke on Windows runners (no drive letter → `to_path` returned
  None); switched to existing `tmp_path` + `file_url` helpers. URI roundtrip
  tests now use `std::path::absolute` so relative-path inputs survive on
  Windows.

## [0.11.5] - 2026-05-06

### Added

- **`hjkl-splash` crate, extracted to a standalone repo + crates.io publish.**
  The HJKL splash-screen animation moved out of `apps/hjkl/src/start_screen.rs`
  into a rendering-agnostic crate at
  [kryptic-sh/hjkl-splash](https://github.com/kryptic-sh/hjkl-splash) so other
  kryptic-sh projects can reuse the same cursor-trail-on-letterforms animation
  in TUI or GUI frontends. Core API emits pure `SplashCell` items via an
  iterator; an optional `ratatui` feature ships a
  `From<Rgb> for ratatui::style::Color` adapter. The hjkl letterforms + path are
  bundled as `presets::hjkl`. Wired in via the existing submodule pattern
  - `[patch.crates-io]`.

### Changed

- **Splash now wall-clock driven (via hjkl-splash 0.2.0).** Animation tick is
  derived from `Instant::now()` inside the crate; consumer code no longer calls
  `screen.advance()`, so the animation cannot stall when high-frequency events
  (mouse motion, focus, resize) starve the event-loop timeout branch.
  `apps/hjkl/src/start_screen.rs` is now a thin ratatui adapter; the
  per-iteration `advance()` call in `apps/hjkl/src/app/event_loop.rs` is
  removed.
- **Compat-oracle: graduate substitute cases to a dedicated nvim-api tier
  (closes #26).** New `corpus/nvim_api_tier.toml` holds the four substitute
  cases that previously sat in `known_divergences.toml`; a new
  `nvim_api_tier_passes` test asserts them via the `hjkl --nvim-api` subprocess
  driver on every CI run (no `HJKL_ORACLE_NVIM_API` gate). The old
  `substitute_via_nvim_api` test is removed; `known_divergences.toml` now tracks
  no active divergences. Driver gains cross-platform binary discovery
  (`std::env::consts::EXE_SUFFIX`) and drops the redundant `echo 1` sync barrier
  — hjkl's `nvim_input` / `nvim_command` handlers process synchronously, so the
  awaited response already implies a settled state.

## [0.11.4] - 2026-05-05

### Added

- **Grammar-load status indicator (closes hjkl#17 acceptance gap).** The status
  line now shows `loading grammar: <name>…` while an async grammar load is in
  flight for the active buffer, and `grammar load failed: <name> — <error>` for
  5 seconds when a load fails. Both use the same takeover pattern as the
  `recording @r` indicator.
- **`:qall` / `:qall!` / `:wqall` / `:wqall!` ex commands (closes #27).**
  hjkl-editor 0.4.0 → 0.4.1 adds dispatch arms for the qall family that the
  canonical-name table already advertised. Reverts the `:q!` workaround in
  nvim-api tests + compat-oracle introduced in v0.11.3.

## [0.11.3] - 2026-05-05

### Added

- **`--nvim-api` msgpack-rpc surface (phase 3 of #26).** New flag boots a
  msgpack-rpc server speaking the neovim wire protocol. Existing `nvim-rs`
  clients can target hjkl as a drop-in subprocess replacement for
  `nvim --headless --embed`. Implemented methods: `nvim_get_current_buf`,
  `nvim_get_current_win`, `nvim_buf_set_lines`, `nvim_buf_get_lines`,
  `nvim_win_set_cursor`, `nvim_win_get_cursor`, `nvim_input`, `nvim_command`,
  `nvim_get_mode`, `nvim_call_function` (`getreg` only). Compat-oracle gains an
  opt-in `HJKL_ORACLE_NVIM_API=1` mode that drives hjkl via msgpack-rpc; the
  four substitute cases pass on this path (still in `known_divergences` for the
  in-process driver since the vim FSM cannot dispatch `:` from a key-replay).
- **Non-blocking grammar loads (hjkl#17 follow-up).** `set_language_for_path` no
  longer blocks the UI thread on first-edit clone+compile. New `GrammarRequest`
  / `SetLanguageOutcome` enums; pending loads tracked on `SyntaxLayer` and
  drained each tick via `poll_pending_loads`. Cache / on-disk fast paths
  preserved — only true clone+compile cases now defer.

### Changed

- **hjkl-bonsai 0.5.0 → 0.5.3.** Adds `AsyncGrammarLoader` (2-worker pool with
  in-flight dedup) and `Grammar::load_from_path` for skipping the loader chain
  when the `.so` + queries are already on disk. Sync `GrammarLoader::load`
  unchanged.
- **hjkl-clipboard 0.5.1 → 0.5.3.** Wayland data_source `send` callback no
  longer blocks the UI thread when the paste receiver doesn't drain; adds
  O_NONBLOCK + POLLOUT deferred drain with a 5s deadline reaper. Self-paste
  short-circuits in `do_get` when we own the source. Fixes
  kryptic-sh/hjkl-clipboard#4 (downstream kryptic-sh/buffr#34).

### Fixed

- `--embed` JSON-RPC + `--nvim-api` msgpack-rpc tests sent `:qa!` for shutdown
  but ex.rs canonicalizes `qa!` to `qall!` and has no handler arm — server
  returned an error, never quit, tests hung 5+ minutes per case. Switched to
  `:q!`. Proper `qall` family handler tracked in #27.

## [0.11.2] - 2026-05-05

### Added

- **`recording @r` status indicator.** The status line now shows `recording @r`
  while a q-macro is being recorded, matching vim's native indicator.
- **`--headless` / `-c CMD` script runner (phase 1 of #26).** Launch hjkl with
  `--headless +cmd` or `-c CMD` to execute ex commands non-interactively and
  exit. Enables scripted batch processing without a terminal.
- **JSON-RPC 2.0 server over stdin/stdout (phase 2 of #26).** `--embed` flag
  starts an embedded RPC server, allowing external tools (LSP clients, editors,
  scripts) to drive hjkl over a structured protocol.

## [0.11.1] - 2026-05-05

### Added

- **`:s` / `:%s` / `:<n>,<m>s` / `:'<,'>s` substitute ex-command.** Vim-style
  pattern + replacement with `g` (all on line), `i` (case-insensitive), `I`
  (force case-sensitive) flags. `c` flag is parsed and silently ignored (no
  confirm UI in v1). `&` and `\1`..`\9` work in the replacement. Empty pattern
  (`:s//rep/`) reuses the last `/` search. Status line shows
  `<N> substitutions on <M> lines`. v1 limitations: `/` is the only delimiter,
  no `\v` very-magic (regex syntax is Rust's `regex` crate). Powered by new
  `hjkl_engine::substitute` module.

### Fixed

Eight vim-compat divergences caught by the cron compat-oracle (#24 closed):

- `x` / `X` now write deleted chars to the unnamed register `"`, so `xp` swap
  and other delete-then-paste idioms round-trip.
- `G` clamps to the last content row instead of landing on a phantom row past
  the trailing newline.
- `dd` on the last line clamps the cursor to the new last line instead of
  leaving it past EOF.
- `d$` cursor lands on the last char of the new line, not one column past.
- `u` after an insert clamps the cursor to the last valid column in Normal mode.
- `da"` eats trailing whitespace (or leading if no trailing) per
  `:help text-objects`, instead of leaving a double space.
- `daB` cursor placement matches vim on multi-line brace blocks.
- `diB` preserves the surrounding newlines on multi-line brace blocks
  (`{\n body \n}` → `{\n}`, not `{}`).

### Changed

- `hjkl-engine` 0.3.6 → 0.3.8 (substitute parser/applier in 0.3.7; divergence
  fixes in 0.3.8).
- `hjkl-editor` 0.3.3 → 0.4.0 (`ExEffect::Substituted` carries `lines_changed`
  for the vim-accurate status message).

## [0.11.0] - 2026-05-05

### Added

- **Markdown fenced code blocks render with sub-language highlighting.**
  ` ```rust ` / ` ```python ` / etc. inside `.md` buffers now show the inner
  code with the target language's tree-sitter highlights instead of plain
  `markup.raw.block`. Powered by `hjkl-bonsai` 0.5's new
  `Highlighter::highlight_with_injections` / `highlight_range_with_injections`
  methods plus a per-process child grammar cache in `LanguageDirectory`. First
  fence of an unseen language pays a one-time clone+compile (worker thread, off
  the input path); subsequent renders are a cache hit.
- **Homebrew tap auto-publish** for `hjkl` on tag push. New
  `pkg/homebrew/hjkl.rb.in` template + `brew-tap` job in `release.yml` renders
  the formula with the just-uploaded macOS sha256s and pushes it to
  `kryptic-sh/homebrew-tap`. Install with `brew install kryptic-sh/tap/hjkl`.
- **`hjkl-compat-oracle` crate** (workspace-only, `publish = false`) — headless
  neovim diff harness for vim-compat regression testing. Spawns
  `nvim --headless --embed` per case, drives both nvim and the hjkl engine
  through identical key inputs, diffs buffer/cursor/mode/registers. Tier 1
  corpus covers 44 motion/operator/text-object/count/insert/undo/register cases
  in `corpus/tier1.toml`. 8 confirmed engine divergences surfaced and tracked
  separately in `corpus/known_divergences.toml` + issue #24. Wired into cron CI
  (`.github/workflows/cron.yml`). Closes #23.
- AUR + Alpine `.apk` install paths added to README and the marketing site.

### Fixed

- `hjkl-engine` 0.3.6: `pos_at_byte` no longer panics on byte indices that land
  inside a multi-byte UTF-8 codepoint. Caught by the cargo-fuzz `handle_key`
  target on a Cyrillic-seeded input after the fuzz workspace + patch-deps
  plumbing in `crates/hjkl-engine/fuzz` was repaired.

### Changed

- `hjkl-bonsai` 0.4.1 → 0.5.0 (new injection methods).
- `hjkl-engine` 0.3.4 → 0.3.6 (added `decode_macro` re-export at crate root in
  0.3.5; UTF-8 fix in 0.3.6).
- Marketing site refreshed for v0.10.x: nine ecosystem crates including
  `hjkl-config`, `<leader>g` git surface tagline, `.apk` in install line.

## [0.10.1] - 2026-05-05

### Docs

- Bump `hjkl-buffer` 0.3.4 → 0.3.5. Inlines former `IMPLEMENTERS.md` invariants
  into rustdoc on the actual types and methods (`Position`, `Edit` + variants,
  `Fold`, `Viewport`, `Span`, `Buffer::set_cursor` / `clamp_position` /
  `ensure_cursor_visible`, `BufferView` render module). Now renders on docs.rs
  next to each symbol and shows up in IDE hover. No binary behavior change.

## [0.10.0] - 2026-05-05

### Added

- **Git pickers under `<leader>g`.** Lazygit-adjacent surface, all bound to the
  `<leader>g` chord:
  - `<leader>gs` — status picker. Modified / staged / untracked entries; preview
    shows the working-tree diff (body + `+`/`-`/space prefix).
  - `<leader>gl` — log picker. Hash dimmed yellow, conventional-commit prefix
    colored by type, lazygit-style author initials with deterministic per-author
    color, preserves chronological sort on empty query.
  - `<leader>gb` — branches picker. Locals bucketed before namespaced and
    remotes; checkout does a pre-flight conflict check (diff HEAD↔target ∩
    workdir status) and aborts with a path preview instead of letting libgit2
    return an opaque `class=Checkout (20); code=Conflict (-13)`.
  - `<leader>gB` — file history picker for the current buffer's path.
  - `<leader>gS` — stash picker. `Alt+P` pops, `Alt+D` drops, Enter applies.
  - `<leader>gt` — tags picker. Sorted by tagger time desc with alpha tiebreak;
    Enter checks out the tag's commit (detached HEAD).
  - `<leader>gr` — remotes picker. Lists configured remotes with branch counts;
    Enter fetches.
- **Auto-reload buffers from disk on focus regain.** `Event::FocusGained`
  triggers `checktime_all()`; non-dirty buffers whose mtime+len changed are
  reloaded silently. Dirty buffers and deleted-on-disk files are flagged with
  vim-style `[changed on disk]` / `[deleted]` suffixes in the status line.
  `:checktime` ex command available for manual sweep. `:write!` overrides the
  `E13: file has changed on disk` guard.
- **Colored git commit header in preview.** `commit` / `Author` / `Date` /
  subject lines styled distinctly; conventional-commit prefix in the subject
  picks up the same color used by the log picker.
- **Drop pristine default buffer when first real file opens.** Empty unnamed
  unmodified default slot is closed automatically once the first `:edit`
  succeeds, so the buffer list stays clean.
- Animated splash background now inherits the terminal background across themes
  (carry-over polish from 0.9.3).

### Changed

- **`hjkl-picker` 0.3 → 0.4.** Picks up the
  `PickerAction::Custom(Box<dyn Any + Send>)` refactor that drops app-specific
  variants (`OpenPath`, `ShowCommit`, `CheckoutBranch`, etc.) from the library,
  plus `handle_key` / `label_styles` / `preserve_source_order` source hooks.
  App-side `AppAction` enum now carries all hjkl-specific intents and is
  downcast in `dispatch_picker_action`.
- **Git status picker moved app-side.** `GitStatusSource` removed from
  `hjkl-picker` to keep the library free of `git2`. Now lives in
  `apps/hjkl/src/picker_git.rs` alongside the other git pickers.
- **`hjkl-bonsai` 0.4.0 → 0.4.1.** Adds `build/` to the crate's `.gitignore` so
  compiled grammar artifacts no longer pollute `git status`.
- Sub-dep patch bumps (no behavior change in this app, picked up via caret):
  `hjkl-buffer` 0.3.4, `hjkl-clipboard` 0.5.1, `hjkl-editor` 0.3.3,
  `hjkl-engine` 0.3.4, `hjkl-form` 0.3.3, `hjkl-ratatui` 0.3.3.

### Fixed

- Branch + log pickers preserve their source-defined sort (locals-first,
  chronological) on empty query instead of falling back to alphabetical.
- Git status preview rendered headers only — now includes the diff body and
  `+`/`-`/space prefix per hunk line.
- Picker fuzzy-match highlight positions are aligned to the visible label
  (post-prefix/icon) rather than the raw entry text.
- `checktime_all()` now runs after `<leader>gb` branch checkout so reloaded
  buffers reflect the new tree without manual `:checktime`.

## [0.9.3] - 2026-05-04

### Added

- Animated start screen on `hjkl` launched without a file argument: centered
  `HJKL` figlet with a cursor walking the letterforms in vim-motion order (h → j
  → k → l), trailing fading `h`/`j`/`k`/`l` glyphs. Any non-Ctrl-C keypress
  dismisses; the dismissing key falls through to normal handling so `:` opens
  the command bar on the same press. Splash inherits the terminal background to
  match the editor body across themes.

### Changed

- Bump `hjkl-bonsai` dep from `"0.3"` to `"0.4"`. Picks up the breaking schema
  refactor where highlight queries are sourced from helix + nvim-treesitter (the
  curated upstreams) rather than each grammar repo's own `queries/` dir.
  Resolves silent partial-install failures for grammars whose upstream layout
  doesn't match the prior hardcoded `query_dir` (xml/dtd were affected at the
  pinned revs).

### Fixed

- Cron CI: `cargo install cargo-fuzz` no longer passes `--locked`. The
  cargo-fuzz published `Cargo.lock` pinned an old `rustix` that uses internal
  `rustc_layout_scalar_valid_range_*` attributes nightly now rejects, breaking
  the fuzz harness install.

## [0.9.2] - 2026-05-03

### Fixed

- `Cargo.lock` updated to match the bumped `hjkl` package version. 0.9.1 shipped
  with the lockfile still pinned to `hjkl 0.9.0`, so
  `cargo build --release --locked --bin hjkl` (the release.yml command) failed
  with "cannot update the lock file because --locked was passed".

## [0.9.1] - 2026-05-03

### Fixed

- `Cargo.lock` regenerated cleanly from `cargo build` instead of partial
  `cargo generate-lockfile`. 0.9.0 release CI failed on
  `cargo zigbuild --locked` because the lockfile was out of sync with the bonsai
  0.3 + apps/hjkl pin bump. No source changes vs 0.9.0.

## [0.9.0] - 2026-05-03

### Changed

- **`hjkl-bonsai` 0.2 → 0.3.** Tree-sitter grammar storage subdir renamed
  `hjkl/grammars/` → `bonsai/grammars/`, and macOS/Windows now follow
  XDG-everywhere instead of `~/Library/Application Support` / `%APPDATA%`.
  Existing grammars under the old paths are not migrated — hjkl re-fetches and
  re-compiles them into `~/.local/share/bonsai/grammars/` on first use. Distro
  packagers shipping pre-built grammars must move from
  `/usr/share/hjkl/grammars/` to `/usr/share/bonsai/grammars/` (the AUR PKGBUILD
  here doesn't ship grammars, so no PKGBUILD change). See
  `crates/hjkl-bonsai/CHANGELOG.md` for full detail.

## [0.8.1] - 2026-05-03

### Added

- **Alpine `.apk` package** in `pkg/alpine/APKBUILD.in`, built in
  `.github/workflows/release.yml` inside an `alpine:latest` container off the
  `x86_64-unknown-linux-musl` release tarball and uploaded to the GitHub release
  alongside the `.deb` / `.rpm` / `.tar.gz` artifacts. Install with
  `apk add --allow-untrusted ./hjkl-*.apk`. Tracks
  [#18](https://github.com/kryptic-sh/hjkl/issues/18).

## [0.8.0] - 2026-05-03

### Added

- **New `hjkl-config` crate** in the workspace (also published as a standalone
  submodule at
  [kryptic-sh/hjkl-config](https://github.com/kryptic-sh/hjkl-config)): shared
  TOML config loader for hjkl-based apps. XDG path resolution, span-aware parse
  errors (line/col/snippet), opt-in `Validate` hook, plus
  `load_layered`/`load_layered_from` for bundled-defaults + user-overrides
  deep-merge. Reusable bounds-check helpers (`ensure_range`, `ensure_non_zero`,
  `ensure_one_of`, `ensure_non_empty_str`) returning `ValidationError` with
  field names baked in.
- **User config support in the `hjkl` editor.** Reads
  `$XDG_CONFIG_HOME/hjkl/config.toml` (or `--config <PATH>` to override).
  Defaults bundled into the binary via `include_str!()` from
  [`apps/hjkl/src/config.toml`](apps/hjkl/src/config.toml) — the source-tree
  file is the single source of truth for defaults; no default values live in
  Rust code. User file is deep-merged on top: only overridden fields need to
  appear there. Unknown keys are an error.
  - Wired settings: `editor.leader` (replaces hardcoded `Space`),
    `editor.tab_width` / `editor.expandtab` (fallback when no `.editorconfig`
    matches), `editor.huge_file_threshold` (replaces `HUGE_FILE_LINES = 50_000`
    const in syntax_glue), `theme.name` (currently only `"dark"` bundled;
    unknown names warn and fall back).
  - `Config::validate()` bounds-checks `tab_width ∈ 1..=16` and
    `huge_file_threshold > 0`. Surfaced via `hjkl: config validation: …` on
    startup; exits with code 2 on failure.
  - Slot 0 gets the user-config Options reapplied via `App::with_config` so
    overrides take effect on the first opened buffer (not just `:e`-opened new
    slots). Readonly state on existing slots is preserved across the swap.
  - 5 new validation tests, 2 new `--config` end-to-end pipeline tests, 2 new
    `with_config` smoke tests covering slot-0 and readonly-preservation.

### Changed

- **`hjkl` CLI migrated from hand-rolled parser to clap derive.** Behavior
  preserved: `-R` / `--readonly`, positional `[FILES]...`, vim-style `+N`,
  `+/PATTERN`, `+perf`, `+picker` all still work. `+`-prefixed tokens are
  pre-processed out of `argv` before clap sees it (clap doesn't natively parse
  `+` flags). `--help` / `--version` are now generated by clap and honor
  `CARGO_PKG_VERSION`.

### Added

- `hjkl --help` now renders an ASCII-art banner (figlet "ANSI Regular" font)
  plus the package version inline. Banner lives in `apps/hjkl/src/art.txt`,
  embedded via `include_str!`. Regenerate with
  `figlet -f "ANSI Regular" hjkl > apps/hjkl/src/art.txt`.
- CLI smoke tests: `--version` returns `CARGO_PKG_VERSION`, long-form help
  contains the embedded art block and the version, vim-token splitter separates
  `+N`/`+/foo` from clap-handled args, vim-token applier sets
  line/pattern/perf/picker correctly.
- Edge-case tests for vim-style tokens: bare `+` survives into the clap stream
  as a positional, `--` ends vim-token processing (`hjkl -- +42` opens a file
  literally named `+42`), repeated `+N` / `+/PAT` overwrite (last-write-wins),
  unknown `+cmd` produces a warning string, `+/` (empty pattern) currently sets
  `pattern = Some("")` (documented quirk), end-to-end `parse_argv` round-trips
  mixed flags + tokens + warnings.

### Changed (internal)

- `parse_args` extracted into pure
  `parse_argv(raw: Vec<String>) -> Result<(Args, Vec<String>)>` for testability;
  the env+stderr wrapper remains as `parse_args` for `main` to call.
  `apply_vim_tokens` now returns warnings instead of printing to stderr.

## [0.7.0] - 2026-05-03

Adopts `hjkl-clipboard` 0.5.0 — the `Backend` trait went public, with new
`BackendKind` / `Capabilities` introspection plus async variants and the
`MockBackend` / `SshAwareBackend` extensions. Umbrella consumes the new API in
two places.

### Added

- New `:clipboard` ex command — prints the active backend kind plus the active
  capability flags
  (`WRITE READ CLEAR AVAILABLE PRIMARY IMAGE RICH_TEXT URI_LIST ASYNC_WRITE …`)
  to the status line. Useful for diagnosing why a yank/paste failed silently
  (e.g. confirming the OSC 52 fallback is active over SSH).
- `TuiHost::clipboard()` accessor exposing the cached `Clipboard` so the
  ex-dispatch layer can introspect without round-tripping the engine.

### Changed

- **`TuiHost::read_clipboard` is capability-aware.** Returns `None` immediately
  when the active backend doesn't advertise `Capabilities::READ` (OSC 52 over
  SSH, mocks without `preset_get`, etc), avoiding a guaranteed `UnsupportedMime`
  round-trip through the Wayland/X11 thread.
- `TuiHost::write_clipboard` checks `Capabilities::WRITE` before attempting the
  set, so a misconfigured mock backend that advertises no write capability
  silently no-ops instead of recording garbage calls.
- `hjkl-clipboard` dep `0.4` → `0.5` (caret-minor — `0.4` does not accept
  `0.5.x`).

## [0.6.0] - 2026-05-03

Migrates the umbrella binary onto `hjkl-bonsai` 0.2.x's runtime grammar loader.
Grammars are no longer baked into the binary; they're cloned, compiled, and
installed on demand the first time the editor encounters a language. Distros
that pre-populate `/usr/share/hjkl/grammars/` skip the on-demand path entirely.

### Changed

- **Release binary shrinks 31 MB → 5.1 MB.** The 27 baked `tree-sitter-*`
  grammar crates that bonsai 0.1.x bundled are gone.
- New `apps/hjkl/src/lang.rs` `LanguageDirectory` facade wraps
  `bonsai::runtime::{GrammarRegistry, GrammarLoader}` and caches loaded
  `Arc<Grammar>` per-name. `App` owns one `Arc<LanguageDirectory>`;
  `SyntaxLayer` and the three `Highlighted*Source` pickers all share it so each
  language `dlopen`s once per process.
- `SyntaxWorker` IPC now ships `Arc<Grammar>` (Send+Sync via tree-sitter's
  `unsafe impl`s + `libloading::Library`'s thread-safety) in place of
  `&'static LanguageConfig`.
- First-ever edit of an unknown file extension now blocks for ~1–3 s on a
  `git clone` + `cc` compile. Subsequent edits of the same language hit the
  user-data install (`<user_data>/hjkl/grammars/`) instantly. System-shipped
  grammars skip the build entirely.
- On-disk layout reorganized (see hjkl-bonsai 0.2.0 changelog for full detail).
  Existing `~/.local/share/hjkl/grammars/sources/` and
  `~/.cache/hjkl/build-grammars/` from v0.5.0 are now orphan and safe to delete.

### Fixed

- Cross-platform user-directory resolution: Windows / macOS no longer bail with
  `$HOME not set` because the loader now uses the `dirs` crate.

### Internal

- Tests that need a real grammar (network clone + cc compile of
  tree-sitter-rust) are gated behind `#[ignore]` so the default `cargo test`
  lane stays offline. Run them with `cargo test -p hjkl -- --ignored`.

## [0.5.0] - 2026-05-03

### Added

- TOML-driven UI + syntax theme matching the `hjkl.kryptic.sh` palette. Themes
  load from baked `themes/{ui,syntax}-dark.toml` at startup;
  `:set background={dark,light}` swaps live.
- Migrated from the legacy `hjkl-tree-sitter` crate to the renamed `hjkl-bonsai`
  0.1.x (same baked-grammar API, just rebadged). No code changes for end users.

### Fixed

- `:wq` (and `:x`) refuse to exit when the save fails. `do_save` / `save_slot`
  now return `bool`; `E32` (no filename), `E45` (readonly), and IO errors no
  longer silently quit and lose unsaved content.

## [0.4.6] - 2026-05-03

### Fixed

- v0.4.5 release failed (all 7 builds): the submodule pointer for
  `hjkl-clipboard` was stale (commit `6170ad0`, pre-0.4.8) but the lockfile
  recorded `hjkl-clipboard 0.4.8`, so `cargo build --locked` in CI rejected the
  mismatch. Pointer advanced to the v0.4.8 tag.

## [0.4.5] - 2026-05-03

### Fixed

- Bumped `hjkl-clipboard` 0.4 → 0.4.8 to pull in the Wayland bind fix. Clipboard
  now works on sway/wlroots and Hyprland (`FIRST_CLIENT_ID = 4` matches
  libwayland-client; older value of 100 was rejected by those compositors with a
  cryptic `"invalid arguments for wl_registry#2.bind"`).

## [0.4.4] - 2026-05-03

### Fixed

- `aur-bin` release job: include `pkg/aur/PKGBUILD-bin.in` in the repo. The AUR
  `.gitignore` allowlist matched only the literal name `PKGBUILD`, silently
  filtering the template file out of the v0.4.3 tag. Job failed at the sed
  render step.

## [0.4.3] - 2026-05-03

### Added

- Auto-publish `hjkl-bin` to AUR on every release. Mirrors buffr's pattern:
  archlinux container fetches sha256 sidecars from the GitHub release tarballs,
  renders `pkg/aur/PKGBUILD-bin.in`, generates `.SRCINFO`, and pushes to
  `aur.archlinux.org/hjkl-bin.git` via the org-level `AUR_SSH_KEY` secret.
  Targets gnu x86_64 + aarch64.

## [0.4.2] - 2026-05-03

### Added

- 8 new tree-sitter languages bundled via `hjkl-tree-sitter` 0.4.0: Python,
  TypeScript, TSX, Go, YAML, Bash, C, HTML, CSS. Auto-detected by file
  extension; highlighting works out of the box for all 14 supported languages.

### Changed

- Bumped `hjkl-tree-sitter` 0.3 → 0.4. Binary size grows ~8–12 MB
  release-stripped from the additional grammar `.so` artifacts.

## [0.4.1] - 2026-05-03

### Changed

- Doc cleanup pass across all submodule READMEs: dropped "spec frozen", "Buffer
  trait sealed", "engine SPEC types" and other stale rhetoric inherited from the
  pre-0.1.0 era.
- `apps/hjkl/src/host.rs` doc comment now describes the clipboard as in-house
  cross-platform rather than "native per-platform".
- `CONTRIBUTING.md` Releases section rewritten — `release-plz` and lockstep
  workspace versioning have been gone for a while; documents the current manual
  BCTP-per-submodule flow.
- Submodule pointer + lockfile bumps: `hjkl-buffer` 0.3.3, `hjkl-engine` 0.3.3,
  `hjkl-clipboard` 0.4.7, `hjkl-tree-sitter` 0.3.2, `hjkl-form` 0.3.2,
  `hjkl-ratatui` 0.3.2, `hjkl-picker` 0.3.2 (all README-only patch releases).

## [0.4.0] - 2026-05-03

### Changed

- **Bumped `hjkl-clipboard` 0.3 → 0.4.** Our in-house clipboard crate replaced
  its `arboard` dependency with a hand-rolled implementation built for the
  kryptic-sh ecosystem. Wayland paste now works on KDE / wlroots / Hyprland
  sessions where the previous backend silently lost the selection. See the
  [`hjkl-clipboard`](https://crates.io/crates/hjkl-clipboard) changelog for the
  full breakdown.
- **`TuiHost` clipboard adapter migrated to the 0.4 API.** `Clipboard::new()` is
  now fallible — wrapped in `Option<Clipboard>` so probe failure degrades to a
  no-op rather than aborting startup. `set_text` / `get_text` replaced with
  typed `set` / `get` over `Selection::Clipboard` + `MimeType::Text`.

### Removed

- **`SPEC.md`** removed from the `hjkl-engine` repo. The trait surface is
  documented inline via rustdoc and published to docs.rs; the parallel
  hand-maintained spec was a churn liability. Umbrella + per-crate READMEs point
  at [docs.rs/hjkl-engine](https://docs.rs/hjkl-engine) instead.

### Internal

- Lockfile shrunk ~371 lines as the `arboard` transitive tree (`wayland-client`,
  `x11rb`, `image`, etc.) was dropped. Smaller `cargo install hjkl` build.
- Bumped `hjkl-engine`, `hjkl-buffer`, `hjkl-editor` 0.3.1 → 0.3.2 (doc-only:
  SPEC.md drop + reference cleanup; no API changes).

## [0.3.4] - 2026-04-30

### Added

- `.rpm` packages for Fedora, RHEL, openSUSE, and other RPM-based distributions
  on x86_64 and aarch64. Built via `cargo-generate-rpm` on the same linux-gnu
  pipeline as the existing `.deb` packages. Install with
  `dnf install ./hjkl-0.3.4-1.x86_64.rpm` (or `aarch64`).

## [0.3.3] - 2026-04-30

### Fixed

- Release CI publish-crates job: only publishes the umbrella `hjkl` app crate.
  Previous logic looped over the eight `hjkl-*` library crates at the workspace
  version, but those ship from their own `kryptic-sh/hjkl-*` repos at
  independent versions, so the loop failed for any version mismatch. The v0.3.2
  GitHub Release was cut with artifacts but never reached crates.io because of
  this; v0.3.3 ships the fix and the matching crates.io upload.

## [0.3.2] - 2026-04-30 [YANKED]

GitHub Release exists with the new artifacts listed below, but
`cargo install hjkl` was never bumped past 0.3.1 — the publish-crates job in
release.yml had a stale loop over now-independent submodule crates and failed
before reaching the umbrella `hjkl` crate. v0.3.3 ships the fix and the matching
crates.io upload.

### Added

- Release matrix expanded from 4 → 7 targets. New artifacts:
  `aarch64-unknown-linux-gnu` (Graviton, Pi, ARM laptops),
  `x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl` (statically
  linked, distro-agnostic, Alpine, Docker scratch images).
- `.deb` packages on both linux-gnu targets via `cargo-deb` —
  `hjkl_0.3.3-1_amd64.deb` and `hjkl_0.3.3-1_arm64.deb` attached to each GitHub
  Release alongside `.sha256` checksums.
- `[package.metadata.binstall]` so `cargo binstall hjkl` Just Works.
- Homebrew tap at
  [`kryptic-sh/homebrew-tap`](https://github.com/kryptic-sh/homebrew-tap):
  `brew install kryptic-sh/tap/hjkl` (manual bump per release).

## [0.3.1] - 2026-04-30

### Changed

- Extracted each `crates/hjkl-*` library into its own `kryptic-sh/hjkl-*`
  repository with full git history preserved. Each library now publishes
  independently to crates.io and is consumed via caret version requirements
  (`hjkl-buffer = "0.3"`, etc.) instead of workspace path deps.
- The `kryptic-sh/hjkl` repo now mounts those crates back under `crates/*` as
  git submodules pinned to `v0.3.1` tags, so a single workspace build still
  touches every layer for development.
- Bumped sibling deps to their `0.3.1` releases: `crossterm` 0.29, `ratatui`
  0.30, `criterion` 0.8, `toml` 1.1.

## [0.3.0] - 2026-04-28

### Added

- **Multi-buffer support** in `apps/hjkl`: open many files at once
  (`hjkl a.rs b.rs c.rs`); tab line at top of screen when more than one buffer
  is open; switch buffers with `:bn` / `:bp` / `:bd[!]` / `:bfirst` / `:blast` /
  `:b N` / `:b name` / `:ls` / `:buffers`; alt buffer via `Ctrl-^` or `:b#`;
  cycle with `Shift-H` / `Shift-L` and `gt` / `gT` / `]b` / `[b`; bulk save/quit
  with `:wa` / `:qa[!]` / `:wqa[!]`; helix-style `:q` closes the active slot
  when more than one buffer is open rather than exiting.
- **Fuzzy file picker** (`<Space><Space>` / `<Space>f` / `:picker` /
  `hjkl +picker`) with syntax-highlighted preview pane.
- **Buffer picker** (`<Space>b` / `:bpicker`) — switch open buffers via the same
  fuzzy UI.
- **Grep picker** (`<Space>/` / `:rg <pattern>`) — ripgrep-backed content search
  with grep and findstr fallbacks for platforms without ripgrep; preview pane
  scrolls to and highlights the match line.
- **Multi-file CLI** — `hjkl a.rs b.rs c.rs` opens all files as named slots.
- **Tab line** at the top of the screen listing all open buffers; rendered only
  when more than one buffer is open.
- **Tree-sitter syntax highlighting** in the buffer pane and picker preview
  (Rust, Markdown, JSON, TOML, SQL bundled via `hjkl-tree-sitter`).
- **Comment marker overlay** (`hjkl-tree-sitter::CommentMarkerPass`) — annotates
  `TODO` / `FIXME` / `FIX` / `NOTE` / `INFO` / `WARN` markers with distinct
  highlight styles; consecutive single-line comments inherit the marker across
  continuation lines.
- **Smart indent** — Enter, `o`, and `O` bump indent one level after `{`, `(`,
  or `[`; a leading close brace on the new line auto-dedents.
- **`.editorconfig` support** — `indent_style`, `indent_size`, `tab_width`, and
  `max_line_length` are applied automatically on file open.
- **`hjkl-picker` crate** (`crates/hjkl-picker`) — the entire picker subsystem
  is extracted into a standalone reusable crate with no direct dependency on
  `hjkl-engine`. Provides `Picker`, `PickerLogic` trait, `FileSource`,
  `RgSource`, `PreviewSpans`, and the fuzzy `score` function.
- **Shared braille spinner** (`hjkl-ratatui::spinner::frame`) — 10-frame braille
  animation at ~8 Hz via a monotonic epoch; used in picker loading indicators.
- **Shared register bank** across buffer slots — yank in one slot, paste in
  another; `dd` and other linewise ops write to the unnamed register so
  cross-buffer paste works correctly.
- **Info popup overlay** for `:reg`, `:marks`, `:jumps`, `:changes` — multi-line
  ex-command output renders as a centered floating popup.
- **Status line additions**: `REC@r` badge while recording a macro; pending
  count and pending operator block; search count `[n/m]`.
- **Cursor-line background** in the editor pane (subtle blue-grey); suppressed
  during `:` and `/` prompts.
- **`Gutter::line_offset`** field in `hjkl-buffer` — enables windowed preview
  snapshots to display original document line numbers in the gutter.
- **`Viewport::tab_width`** field — carries the active `tabstop` value through
  the render pipeline.
- **`:set softtabstop=N`** (`sts`) — Backspace deletes a soft tab as a unit; Tab
  fills to the next softtabstop boundary.

### Changed

- **Default tab settings** flipped to modern four-space soft tabs:
  `tabstop=4 shiftwidth=4 softtabstop=4 expandtab=on smartindent=on`. To revert
  to traditional vi defaults:
  `:set noexpandtab tabstop=8 shiftwidth=8 softtabstop=0`.
- **`:set tabstop=N`** is now threaded through `Viewport.tab_width` to the
  renderer and cursor position math end-to-end.
- **Picker prompt symbol** changed from `>` to `/` to match search semantics.
- **`PickerLogic` trait** gains `preview_top_row`, `preview_match_row`, and
  `preview_line_offset` so windowed sources can position, highlight, and
  label-offset the preview pane correctly.
- **Label spacing** across all pickers is uniform (2-cell prefix pad, no leader
  arrow).

### Fixed

- **Tab rendering** — tab characters expand to spaces aligned to tab stops;
  `cursor_screen_pos` accounts for tab visual width.
- **`dd` resets `sticky_col`** so subsequent `j` / `k` lands on the first
  non-blank column rather than the deleted line's column.
- **Paste linewise** reads from the unnamed register slot rather than a
  per-editor cache, fixing cross-buffer linewise paste.
- **Grep picker preview** is no longer empty (status-tag misuse) and now scrolls
  to the match line with correct file line numbers in the gutter.

## [0.1.1] - 2026-04-27

### Fixed

- `hjkl-editor` shell filter (`%!cmd`) now tolerates the child closing stdin
  before all input is consumed. Previously a `BrokenPipe` write error would
  short-circuit and mask the child's actual exit status (e.g. `%!exit 5`
  reported "cannot write to `exit 5`: Broken pipe" instead of "command exited
  5"). Now `BrokenPipe` falls through to `wait_with_output()` so the real exit
  status wins; other write errors still surface. Fixes a flaky CI failure on
  `shell_filter_failing_command_errors`.

### CI

- Replaced `release-plz.yml` with a tag-driven `release.yml` matching the
  org-wide canonical pattern. Runs fmt/clippy/test as a quality gate, then
  publishes the 4 hjkl crates to crates.io in dep order via an idempotent shell
  loop (curl-precheck + `cargo publish --locked`). Fires on
  `git push origin vX.Y.Z`.

## [0.1.0] - 2026-04-27

### Patch C-δ — Editor generic flip + SPEC freeze

The first stability-locked release. Folds the 0.0.20 — 0.0.42 churn (none
published to crates.io) into a single 0.1.0 cut: SPEC trait scaffolding, the
Buffer/Cursor/Query/BufferEdit/Search trait split, viewport relocation onto
`Host` (driven by GUI/TUI cross-platform requirement), motion + editor + vim
reach lifts, the `Editor` generic flip, removal of the pre-0.1.0 dyn-host shim,
and the SPEC freeze.

docs.rs surfaces the canonical API per upload.

#### Pre-1.0 trait-extraction arc (folded into 0.1.0)

23 unpublished patches (0.0.20 — 0.0.42) led up to the freeze. The arc themes:

- **Buffer surface discipline** (0.0.20 — 0.0.31): SPEC trait scaffolding;
  `Cursor` / `Query` / `BufferEdit` / `Search` component traits; `Sealed`
  super-trait gating downstream impls; <40-method cap on `Buffer`.
- **Viewport lift to Host** (0.0.32 — 0.0.34): `Buffer::viewport` field deleted;
  viewport storage + accessors moved to the `Host` trait so vim logic works in
  GUIs (pixel canvas, variable-width fonts) as well as TUIs (cell grid).
- **Editor field consolidation** (0.0.35 — 0.0.39): marks
  (`BTreeMap<char, Pos>`) and search state migrated from `Buffer` to `Editor`;
  `Buffer::dirty_gen` added for invalidation; `FoldProvider` + `FoldOp` lifted
  onto canonical engine surface.
- **Generic body** (0.0.40 — 0.0.42): 24 motion fns lifted to
  `B: Cursor + Query [+ &dyn FoldProvider]`; 70 + 151 internal `self.buffer.…` /
  `ed.buffer().…` reaches in editor.rs + vim.rs routed through the trait
  surface; viewport-math fns relocated as engine-side free fns;
  `apply_buffer_edit` seam centralized as the single concrete-on-`hjkl_buffer`
  funnel.

Buffer trait final shape: 14 methods. The full per-patch detail lives in git
(`git log v0.0.19..v0.1.0`); CHANGELOG entries for those patches are folded here
to keep crates.io history clean.

#### BREAKING — `Editor` generic over `B` + `H`

```rust
// 0.0.42 (and earlier):
pub struct Editor<'a> { /* concrete buffer + boxed dyn host */ }

// 0.1.0:
pub struct Editor<
    B: hjkl_engine::types::Buffer = hjkl_buffer::Buffer,
    H: hjkl_engine::types::Host = hjkl_engine::types::DefaultHost,
> { /* typed buffer + typed host */ }
```

The `'a` lifetime parameter (vestigial since the textarea field was ripped) is
removed. Defaults match the in-tree canonical impls so most call sites that
named `Editor` without a type parameter continue to type-check. Call sites that
wrote `Editor<'_>` / `Editor<'static>` need to drop the lifetime.

The vim FSM (`crate::vim` free functions, `Editor::mutate_edit`, the change-log
emitter, and the undo machinery) is bound to the canonical buffer:

```rust
impl<H: hjkl_engine::types::Host> Editor<hjkl_buffer::Buffer, H> {
    /* most methods */
}
```

The fully generic `<B: Buffer, H: Host>` impl exposes only universal accessors
(`buffer()` / `buffer_mut()` / `host()` / `host_mut()`). Custom buffer backends
compile against the trait but cannot run the vim FSM at 0.1.0 — see SPEC.md
§"Out of scope at 0.1.0" for the rationale (lifting `BufferEdit::apply_edit`
onto an associated type is post-0.1.0 work).

#### BREAKING — constructor

```rust
// 0.0.42:
Editor::new(KeybindingMode::Vim)
Editor::with_host(KeybindingMode::Vim, host)
Editor::with_options(buffer, host, options)

// 0.1.0:
Editor::new(buffer, host, options)
```

The legacy three-constructor surface is gone. There is no `#[deprecated]` shim —
every consumer migrates by passing the buffer, host, and `crate::types::Options`
explicitly. Call sites that don't need a custom host pass
`crate::types::DefaultHost::new()`; call sites that don't need custom options
pass `crate::types::Options::default()`.

The `Options::default()` defaults are SPEC-faithful (vim parity, `shiftwidth=8`
/ `tabstop=8`); the pre-0.1.0 `Settings::default()` defaulted to `shiftwidth=2`
(sqeel-derived). Tests and consumers that relied on `shiftwidth=2` need to
construct an `Options` with `shiftwidth: 2` explicitly.

#### BREAKING — `EngineHost` removed

The pre-0.1.0 object-safe shim trait (`EngineHost`) and its blanket
`impl<H: Host>` are gone. Hosts implement `Host` directly; the `Editor<B, H>`
generic carries the typed slot. `Editor::host()` now returns `&H` (was
`&dyn EngineHost`); `Editor::host_mut()` returns `&mut H`. Callers using the
host accessor need `crate::types::Host` in scope to call its methods through
trait dispatch.

#### BREAKING — `Buffer` trait sealed (preserved)

The `Buffer` super-trait is sealed via the private
`crate::types::sealed::Sealed` trait (already in place since 0.0.31). The
`Sealed` trait is now confirmed `pub(crate)`; downstream cannot
`impl Buffer for MyType` after this change. The canonical `hjkl_buffer::Buffer`
keeps its sealed-marker impl in `crate::buffer_impl`.

#### `apply_buffer_edit` decision

The seam between the engine and `hjkl_buffer::Buffer` for the mutate-edit
channel stays concrete on `&mut hjkl_buffer::Buffer` per the option (c) decision
documented in 0.0.42's CHANGELOG. SPEC.md §"Out of scope at 0.1.0" calls this
out explicitly: lifting onto `BufferEdit::Op` (an associated `type Edit;`) is
post-0.1.0 work — it forces every backend to design its own rich-edit enum and
rewrites the change-log machinery in terms of `B::Op`. The single seam at
`crate::buf_helpers::apply_buffer_edit` is the migration funnel for 0.2.0.

#### `EditorSnapshot::VERSION` frozen

`EditorSnapshot::VERSION` (currently `4`) is locked for the entire 0.1.x line.
Hosts persisting editor state between sessions can rely on the wire format being
stable; 0.2.0+ structural changes require `VERSION++` together with a
major-version bump.

#### SPEC.md frozen

`crates/hjkl-engine/SPEC.md` carries an explicit "0.1.0 (frozen 2026-04-27)"
header. The trait surface (14 `Buffer` methods across `Cursor` / `Query` /
`BufferEdit` / `Search`), `Host` trait surface, `FoldProvider` + `FoldOp`,
`Options`, `EditorSnapshot`, and the `Editor::new(buffer, host, options)`
constructor are all part of the frozen contract. Explicit non-goals: viewport
math on `Buffer`, `Editor`'s apply-edit funnel as part of the public trait
surface, and any host-flavoured fold-op enum (engine-canonical only).

#### `PUBLIC_API.md` regenerated

`crates/hjkl-engine/PUBLIC_API.md` is regenerated against 0.1.0 with the
simplified `cargo +nightly public-api` output. Top-level diff vs the 0.0.39
baseline:

- `Editor<'a>` → `Editor<B: Buffer, H: Host>` (every method now carries the new
  type params; the vim FSM impl is on `Editor<hjkl_buffer::Buffer, H>`).
- `Editor::new(KeybindingMode)` removed; `Editor::new(buffer, host, Options)`
  added.
- `Editor::with_host` / `Editor::with_options` removed.
- `EngineHost` trait + blanket impl removed.
- `motions::*` free functions now generic over `B: Cursor + Query [+ Search]`
  (vs concrete `&mut hjkl_buffer::Buffer` at 0.0.39 — they were lifted in 0.0.40
  but the PUBLIC_API.md hadn't been refreshed since 0.0.33 baseline).
- `BufferEdit::replace_all` added (already landed in 0.0.41; surfaced in this
  release's PUBLIC_API.md regeneration).

#### Tests

684 (unchanged from 0.0.42). One test (`bare_set_reports_current_values`)
updated to assert `shiftwidth=8` per the SPEC-faithful default; one golden
snapshot (`golden_ex__set_listing.snap`) regenerated for the same reason. The
vim-FSM-internal `editor_with` helper explicitly sets `shiftwidth: 2` so the
indent / outdent assertions don't churn.

#### Migration

Consumers updating from 0.0.x:

```rust
// before
let mut editor = Editor::new(KeybindingMode::Vim);

// after
use hjkl_engine::types::{DefaultHost, Options};
let mut editor = Editor::new(
    hjkl_buffer::Buffer::new(),
    DefaultHost::new(),
    Options::default(),
);
```

For consumers that wrote `&mut Editor<'_>` in fn signatures:

```rust
// before
fn step(ed: &mut Editor<'_>, input: Input) { ... }

// after
fn step<H: hjkl_engine::Host>(
    ed: &mut Editor<hjkl_buffer::Buffer, H>,
    input: Input,
) { ... }
```

For consumers that constructed an `Editor` with a custom host:

```rust
// before
let mut editor = Editor::with_host(KeybindingMode::Vim, my_host);

// after
let mut editor = Editor::new(
    hjkl_buffer::Buffer::new(),
    my_host,
    hjkl_engine::types::Options::default(),
);
```

## [0.0.19] - 2026-04-26

### Added

- `smartcase` honoured by `/` and `?` searches. When `ignorecase` is on and the
  pattern contains an uppercase letter, the search compiles case-sensitive
  (matches vim's combined `ignorecase` + `smartcase` behaviour). Wired through
  `Settings::smartcase`, `Options::smartcase`, and `:set smartcase` / `:set scs`
  / `:set nosmartcase`.
- `:set` listing now includes `smartcase=on/off`. Golden snapshot updated.

## [0.0.18] - 2026-04-26

### Added

- `expandtab` honoured by the insert-mode Tab key. When `Settings::expandtab` is
  true, Tab inserts `tabstop` spaces; otherwise a literal `\t` (existing
  behaviour). Wired through `Options::expandtab`, `current_options` /
  `apply_options`, `:set expandtab` / `:set noexpandtab` / `:set et`.
- `:set` listing now includes `expandtab=on/off`. Golden snapshot updated.

## [0.0.17] - 2026-04-26

### Added

- `Options::textwidth` (u32, default 79) — engine-native bridge for the legacy
  `Settings::textwidth` driving `gq{motion}` reflow. Wired through
  `current_options` / `apply_options` and `set_by_name("tw"|"textwidth")`.

## [0.0.16] - 2026-04-26

### Added

- `Options::wrap: WrapMode` (engine-native equivalent of `hjkl_buffer::Wrap`).
  `Editor::current_options` / `apply_options` map between `WrapMode` and
  `hjkl_buffer::Wrap` at the boundary.
- `set_by_name` / `get_by_name` recognise vim's `wrap` and `linebreak` (`lbr`)
  toggles. Combined state collapses into the single `WrapMode` field:
  `wrap=off → None`, `wrap=on,lbr=off → Char`, `wrap=on,lbr=on → Word`.

## [0.0.15] - 2026-04-26

### Added

- IncSearch highlight emission. `Editor::highlights_for_line` now branches:
  active `/` or `?` prompt → `HighlightKind::IncSearch` for live-preview
  matches; committed pattern → `SearchMatch` (existing behaviour). Hosts can
  paint live-preview distinctly from committed-search.
- Insta golden snapshots for ex-command output
  (`crates/hjkl-editor/tests/golden_ex.rs`): `:registers`, `:marks`, bare
  `:set`. Catches user-visible text format churn.

## [0.0.14] - 2026-04-26

### Changed (potentially breaking)

**Trait sealing pass.** Every `#[doc(hidden)] pub` item exposed for cross-crate
ex.rs reach is now sealed behind a proper public method. Hosts that were poking
at `Editor`'s internal fields (and ignoring the `#[doc(hidden)]` warning) now go
through the methods.

Field visibility flipped from `pub` to `pub(crate)`:

- `Editor::vim`, `Editor::registers`, `Editor::settings`, `Editor::file_marks`,
  `Editor::syntax_fold_ranges`, `Editor::undo_stack`, `Editor::change_log`.

`VimState::last_edit_pos`, `jump_back`, `marks` flipped back to `pub(super)` (no
longer reachable from outside hjkl-engine). `vim::do_undo` / `vim::do_redo`
flipped from `pub` to `pub(crate)`; the crate-root re-export is gone.

### Added (replacing the sealed surface)

New Editor methods covering everything ex.rs (and any other host) previously
reached via raw fields:

- `Editor::syntax_fold_ranges() -> &[(usize, usize)]`
- `Editor::file_marks()` — iterator over uppercase marks
- `Editor::buffer_mark(c) -> Option<(usize, usize)>`
- `Editor::buffer_marks()` — iterator over lowercase marks
- `Editor::last_jump_back() -> Option<(usize, usize)>`
- `Editor::last_edit_pos() -> Option<(usize, usize)>`
- `Editor::pop_last_undo() -> bool`
- `Editor::undo()` / `Editor::redo()`

Previously `#[doc(hidden)]` methods on `Editor` are now plain `pub`:

- `jump_cursor`, `mutate_edit`, `push_undo`, `restore`, `settings_mut`.

Fresh rustdoc covers every promoted method.

### Migration

Code that read fields directly should switch to method calls. For write-side
mutation (`undo_stack.pop()` etc.), `pop_last_undo()` is the supported
replacement.

## [0.0.13] - 2026-04-26

### Added

- `Editor::feed_input(PlannedInput) -> bool` — SPEC Input dispatch. Bridges
  hosts that don't carry crossterm (buffr CEF, future GUI shells) into the
  engine. Char + Key variants route to handle_key; Mouse / Paste / FocusGained /
  FocusLost / Resize fall through.

## [0.0.12] - 2026-04-26

### Added

- `Editor::intern_engine_style(types::Style) -> u32` — SPEC-typed style
  interning. Same opaque ids as the ratatui-flavoured `intern_style`; both share
  the underlying table.
- `Editor::engine_style_at(id) -> Option<types::Style>` — looks up an interned
  style by id, returns it as a SPEC type. Hosts that don't depend on ratatui
  (buffr, future GUI shells) reach this surface for syntax-span installation.

## [0.0.11] - 2026-04-26

### Added

- `Editor::take_changes() -> Vec<EditOp>` — pull-model SPEC change drain. Editor
  accumulates EditOp records on every mutate_edit; take_changes drains the
  queue. Best-effort mapping for compound buffer edits (JoinLines, InsertBlock,
  etc.) emits a placeholder covering the touched range.
- `Editor::current_options() -> Options` and `Editor::apply_options(&Options)`
  bridge between SPEC Options and legacy Settings. Lets hosts read/write engine
  config through the SPEC API.

## [0.0.10] - 2026-04-26

### Added

- `hjkl-engine::types::OptionValue { Bool, Int, String }` — typed value carrier
  for the `:set` parser.
- `Options::set_by_name(name, OptionValue) -> Result<(), EngineError>` and
  `Options::get_by_name(name) -> Option<OptionValue>`. Vim-style short aliases
  supported (`ts`, `sw`, `et`, `isk`, `ic`, `scs`, `hls`, `is`, `ws`, `ai`,
  `tm`, `ul`, `ro`).

## [0.0.9] - 2026-04-26

### Changed (breaking the 0.0.8 snapshot wire format)

- `EditorSnapshot::VERSION` bumped to `3`. Adds a
  `file_marks: HashMap<char, (u32, u32)>` field carrying the uppercase / "file"
  marks (`'A`–`'Z`). Survives `set_content`, so hosts persisting between tab
  swaps round-trip mark state. 0.0.8 snapshots fail `restore_snapshot` with
  `EngineError::SnapshotVersion`.

## [0.0.8] - 2026-04-26

### Changed (breaking the 0.0.7 snapshot wire format)

- `EditorSnapshot::VERSION` bumped to `2`. The struct gains a
  `registers: Registers` field carrying vim's `""`, `"0`, `"1`–`"9`, `"a`–`"z`,
  and `"+`/`"*` slots. 0.0.7 snapshots fail `restore_snapshot` with
  `EngineError::SnapshotVersion`.
- `Slot` and `Registers` derive `Serialize` / `Deserialize` behind the `serde`
  feature.

## [0.0.7] - 2026-04-26

### Added

- `hjkl-engine::types::RenderFrame` — borrow-style render frame the host
  consumes once per redraw. Coarse today: mode + cursor + cursor_shape +
  viewport_top + line_count.
- `Editor::render_frame()` builder.
- `Editor::highlights_for_line(u32)` — SPEC `Highlight` emission with
  `HighlightKind::SearchMatch` for search hits.
- `Editor::selection_highlight()` — bridges the active visual selection to a
  SPEC `Highlight` with `HighlightKind::Selection`. None outside visual modes;
  visual-line / visual-block collapse to their bounding char range.

### Changed

- `CursorShape` now derives `Hash` so `RenderFrame` can derive it.

## [0.0.6] - 2026-04-26

### Added

- `hjkl-engine::types::EditorSnapshot` — coarse serde-friendly snapshot of
  editor state for host persistence. Carries `version`, `mode`, `cursor`,
  `lines`, `viewport_top`. Bumps the snapshot `EditorSnapshot::VERSION` constant
  to track wire-format compat.
- `hjkl-engine::types::SnapshotMode` — status-line mode summary embedded in the
  snapshot.
- `Editor::take_snapshot()` — produces an `EditorSnapshot` at the current state.
- `Editor::restore_snapshot(snap)` — restores from a snapshot; returns
  `EngineError::SnapshotVersion` on wire-format mismatch.

## [0.0.5] - 2026-04-26

### Changed

- **`ex.rs` relocated from `hjkl-engine` to `hjkl-editor`.** Ex commands now
  live in the crate they belong to. Consumers reach `ex` via
  `hjkl_editor::runtime::ex` (unchanged surface — the facade was already routing
  there).
- `hjkl-editor` gains `regex` as a direct dep (ex uses it for `:s/pat/.../`) and
  `crossterm` as a dev-dep.
- `mark_dirty_after_ex` is now a free function. Ex callsites that previously
  wrote `editor.mark_dirty_after_ex()` now write `mark_dirty_after_ex(editor)`.

### Added (engine internal — sealed at 0.1.0)

Several `pub(super)` / `pub(crate)` items on `Editor` and `VimState` gained
`#[doc(hidden)] pub` visibility so ex commands can reach them across the crate
boundary:

- `Editor`: `vim`, `undo_stack`, `registers`, `settings`, `file_marks`,
  `syntax_fold_ranges` fields; `settings_mut`, `mutate_edit`, `push_undo`,
  `restore`, `jump_cursor` methods.
- `VimState`: `last_edit_pos`, `jump_back`, `marks` fields.
- `vim::do_undo`, `vim::do_redo` re-exported at the crate root.

These are explicit churn-phase exposures and will be sealed under the 0.1.0
trait extraction. Do not rely on them.

### Migrated tests

5 vim+ex integration tests (`gqq` reflow, `gq` motion, paragraph break
preservation, `gqq` undo, `:marks` listing) moved from
`crates/hjkl-engine/src/vim.rs` to
`crates/hjkl-editor/tests/vim_ex_integration.rs`. cargo dev-dep cycles between
hjkl-engine and hjkl-editor produce duplicate type IDs, so they must run from
the editor side.

## [0.0.4] - 2026-04-26

### Changed

- Workspace `homepage` set to <https://hjkl.kryptic.sh>.
- Per-crate READMEs now carry CI / crates.io version / docs.rs / License /
  Website badges and a Website + Source line.

## [0.0.3] - 2026-04-26

### Added

- `hjkl-engine::Editor::take_content_change()` — pull-model coarse change
  observation. Returns `Some(Arc<String>)` if content changed since the last
  call, `None` otherwise. Drains the dirty flag. Bridges the gap until SPEC's
  `take_changes() -> Vec<EditOp>` ships with full edit-path instrumentation.
- `hjkl-engine::types::Viewport` (re-exported as `PlannedViewport` at the crate
  root to disambiguate from `hjkl_buffer::Viewport`).
- `hjkl-engine::types::BufferId` opaque newtype.
- 513-case proptest harness for the FSM (`tests/proptest_fsm.rs`): random
  keystroke sequences never panic, and three Escapes always return to Normal
  mode.

## [0.0.2] - 2026-04-26

### Added

- `hjkl-engine::types` extended with the planned 0.1.0 trait surface: `Options`,
  `EngineError`, `Modifiers`, `SpecialKey`, `MouseEvent`, `MouseKind`, `Input`,
  `Host` trait. All additive — coexists with the legacy runtime types in
  `hjkl-engine::editor`.
- `hjkl-editor`: real facade crate (was placeholder). Exposes three modules:
  `buffer`, `runtime`, `spec`. Consumers depend on hjkl-editor alone instead of
  all three downstream crates.
- `hjkl-buffer/IMPLEMENTERS.md`: caller invariants documentation.
- `hjkl-buffer` criterion benches under the `budgets` harness:
  `insert_char_1MB_buffer`, `search_next_10k_lines`, `cold_load_10MB`.
- `hjkl-buffer` proptest roundtrip property suite for `apply_edit` (768 random
  scenarios per test run).

### Changed

- `hjkl-engine::types::Edit` re-exported from the crate root as `EditOp` to
  disambiguate from `hjkl_buffer::Edit`.

## [0.0.1] - 2026-04-26

### Added

- `hjkl-buffer`: full sqeel-buffer port with cursor, edits, motions, folds,
  viewport, search. ratatui Widget impl behind optional `ratatui` feature.
  Default features off — buffer is UI-agnostic.
- `hjkl-engine`: full sqeel-vim port with vim FSM, ex commands, registers,
  dot-repeat, marks. ratatui + crossterm currently mandatory; phase 5 trait
  extraction will move them behind features.
- `hjkl-engine::types` module: SPEC core types (`Pos`, `Selection`,
  `SelectionKind`, `SelectionSet`, `Edit`, `Mode`, `CursorShape`, `Style`,
  `Color`, `Attrs`, `Highlight`, `HighlightKind`). Additive alongside the legacy
  public API; trait extraction wires the FSM and Editor onto these
  progressively.

### Changed

- `hjkl-editor` and `hjkl-ratatui`: still placeholder; ship 0.0.1 to keep
  lockstep workspace version.

## [0.0.0] - 2026-04-26

### Added

- Initial placeholder release. Reserves `hjkl-engine`, `hjkl-buffer`,
  `hjkl-editor`, and `hjkl-ratatui` names on crates.io. No public API.
- `MIGRATION.md` — extraction plan and design rationale.

[Unreleased]: https://github.com/kryptic-sh/hjkl/compare/v0.41.3...HEAD
[0.41.3]: https://github.com/kryptic-sh/hjkl/compare/v0.41.2...v0.41.3
[0.41.2]: https://github.com/kryptic-sh/hjkl/compare/v0.41.1...v0.41.2
[0.41.1]: https://github.com/kryptic-sh/hjkl/compare/v0.40.0...v0.41.1
[0.41.0]: https://github.com/kryptic-sh/hjkl/compare/v0.40.0...v0.41.0
[0.40.0]: https://github.com/kryptic-sh/hjkl/compare/v0.39.1...v0.40.0
[0.39.1]: https://github.com/kryptic-sh/hjkl/compare/v0.39.0...v0.39.1
[0.39.0]: https://github.com/kryptic-sh/hjkl/compare/v0.38.0...v0.39.0
[0.38.0]: https://github.com/kryptic-sh/hjkl/compare/v0.37.1...v0.38.0
[0.37.1]: https://github.com/kryptic-sh/hjkl/compare/v0.37.0...v0.37.1
[0.37.0]: https://github.com/kryptic-sh/hjkl/compare/v0.36.0...v0.37.0
[0.36.0]: https://github.com/kryptic-sh/hjkl/compare/v0.35.0...v0.36.0
[0.35.0]: https://github.com/kryptic-sh/hjkl/compare/v0.34.2...v0.35.0
[0.34.2]: https://github.com/kryptic-sh/hjkl/compare/v0.34.1...v0.34.2
[0.34.1]: https://github.com/kryptic-sh/hjkl/compare/v0.34.0...v0.34.1
[0.34.0]: https://github.com/kryptic-sh/hjkl/compare/v0.33.6...v0.34.0
[0.33.6]: https://github.com/kryptic-sh/hjkl/compare/v0.33.5...v0.33.6
[0.33.5]: https://github.com/kryptic-sh/hjkl/compare/v0.33.4...v0.33.5
[0.33.4]: https://github.com/kryptic-sh/hjkl/compare/v0.33.3...v0.33.4
[0.33.3]: https://github.com/kryptic-sh/hjkl/compare/v0.33.2...v0.33.3
[0.33.2]: https://github.com/kryptic-sh/hjkl/compare/v0.33.0...v0.33.2
[0.33.0]: https://github.com/kryptic-sh/hjkl/compare/v0.32.0...v0.33.0
[0.32.0]: https://github.com/kryptic-sh/hjkl/compare/v0.31.0...v0.32.0
[0.31.0]: https://github.com/kryptic-sh/hjkl/compare/v0.30.0...v0.31.0
[0.30.0]: https://github.com/kryptic-sh/hjkl/compare/v0.29.0...v0.30.0
[0.29.0]: https://github.com/kryptic-sh/hjkl/compare/v0.28.1...v0.29.0
[0.28.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.28.1
[0.28.0]: https://github.com/kryptic-sh/hjkl/compare/v0.27.0...v0.28.0
[0.27.0]: https://github.com/kryptic-sh/hjkl/compare/v0.26.0...v0.27.0
[0.26.0]: https://github.com/kryptic-sh/hjkl/compare/v0.25.1...v0.26.0
[0.25.1]: https://github.com/kryptic-sh/hjkl/compare/v0.25.0...v0.25.1
[0.25.0]: https://github.com/kryptic-sh/hjkl/compare/v0.24.4...v0.25.0
[0.24.4]: https://github.com/kryptic-sh/hjkl/compare/v0.24.3...v0.24.4
[0.24.3]: https://github.com/kryptic-sh/hjkl/compare/v0.24.2...v0.24.3
[0.24.2]: https://github.com/kryptic-sh/hjkl/compare/v0.24.1...v0.24.2
[0.24.1]: https://github.com/kryptic-sh/hjkl/compare/v0.24.0...v0.24.1
[0.24.0]: https://github.com/kryptic-sh/hjkl/compare/v0.22.0...v0.24.0
[0.22.0]: https://github.com/kryptic-sh/hjkl/compare/v0.21.35...v0.22.0
[0.21.35]: https://github.com/kryptic-sh/hjkl/compare/v0.21.34...v0.21.35
[0.21.34]: https://github.com/kryptic-sh/hjkl/compare/v0.21.33...v0.21.34
[0.21.33]: https://github.com/kryptic-sh/hjkl/compare/v0.21.32...v0.21.33
[0.21.32]: https://github.com/kryptic-sh/hjkl/compare/v0.21.31...v0.21.32
[0.21.31]: https://github.com/kryptic-sh/hjkl/compare/v0.21.30...v0.21.31
[0.21.30]: https://github.com/kryptic-sh/hjkl/compare/v0.21.29...v0.21.30
[0.21.29]: https://github.com/kryptic-sh/hjkl/compare/v0.21.28...v0.21.29
[0.21.28]: https://github.com/kryptic-sh/hjkl/compare/v0.21.27...v0.21.28
[0.21.27]: https://github.com/kryptic-sh/hjkl/compare/v0.21.26...v0.21.27
[0.21.26]: https://github.com/kryptic-sh/hjkl/compare/v0.21.25...v0.21.26
[0.21.25]: https://github.com/kryptic-sh/hjkl/compare/v0.21.24...v0.21.25
[0.21.24]: https://github.com/kryptic-sh/hjkl/compare/v0.21.23...v0.21.24
[0.21.23]: https://github.com/kryptic-sh/hjkl/compare/v0.21.22...v0.21.23
[0.21.22]: https://github.com/kryptic-sh/hjkl/compare/v0.21.21...v0.21.22
[0.21.21]: https://github.com/kryptic-sh/hjkl/compare/v0.21.20...v0.21.21
[0.21.20]: https://github.com/kryptic-sh/hjkl/compare/v0.21.19...v0.21.20
[0.21.19]: https://github.com/kryptic-sh/hjkl/compare/v0.21.18...v0.21.19
[0.21.18]: https://github.com/kryptic-sh/hjkl/compare/v0.21.17...v0.21.18
[0.21.17]: https://github.com/kryptic-sh/hjkl/compare/v0.21.16...v0.21.17
[0.21.16]: https://github.com/kryptic-sh/hjkl/compare/v0.21.15...v0.21.16
[0.21.15]: https://github.com/kryptic-sh/hjkl/compare/v0.21.14...v0.21.15
[0.21.14]: https://github.com/kryptic-sh/hjkl/compare/v0.21.13...v0.21.14
[0.21.13]: https://github.com/kryptic-sh/hjkl/compare/v0.21.12...v0.21.13
[0.21.12]: https://github.com/kryptic-sh/hjkl/compare/v0.21.11...v0.21.12
[0.21.11]: https://github.com/kryptic-sh/hjkl/compare/v0.21.10...v0.21.11
[0.21.10]: https://github.com/kryptic-sh/hjkl/compare/v0.21.9...v0.21.10
[0.21.9]: https://github.com/kryptic-sh/hjkl/compare/v0.21.8...v0.21.9
[0.21.8]: https://github.com/kryptic-sh/hjkl/compare/v0.21.7...v0.21.8
[0.21.7]: https://github.com/kryptic-sh/hjkl/compare/v0.21.6...v0.21.7
[0.21.6]: https://github.com/kryptic-sh/hjkl/compare/v0.21.5...v0.21.6
[0.21.5]: https://github.com/kryptic-sh/hjkl/compare/v0.21.4...v0.21.5
[0.21.4]: https://github.com/kryptic-sh/hjkl/compare/v0.21.3...v0.21.4
[0.21.3]: https://github.com/kryptic-sh/hjkl/compare/v0.21.2...v0.21.3
[0.21.2]: https://github.com/kryptic-sh/hjkl/compare/v0.21.1...v0.21.2
[0.21.1]: https://github.com/kryptic-sh/hjkl/compare/v0.21.0...v0.21.1
[0.21.0]: https://github.com/kryptic-sh/hjkl/compare/v0.20.4...v0.21.0
[0.20.4]: https://github.com/kryptic-sh/hjkl/compare/v0.20.3...v0.20.4
[0.20.3]: https://github.com/kryptic-sh/hjkl/compare/v0.20.2...v0.20.3
[0.20.2]: https://github.com/kryptic-sh/hjkl/compare/v0.20.1...v0.20.2
[0.20.1]: https://github.com/kryptic-sh/hjkl/compare/v0.20.0...v0.20.1
[0.20.0]: https://github.com/kryptic-sh/hjkl/compare/v0.19.3...v0.20.0
[0.19.3]: https://github.com/kryptic-sh/hjkl/compare/v0.19.2...v0.19.3
[0.19.2]: https://github.com/kryptic-sh/hjkl/compare/v0.19.1...v0.19.2
[0.19.1]: https://github.com/kryptic-sh/hjkl/compare/v0.19.0...v0.19.1
[0.19.0]: https://github.com/kryptic-sh/hjkl/compare/v0.18.2...v0.19.0
[0.18.2]: https://github.com/kryptic-sh/hjkl/compare/v0.18.1...v0.18.2
[0.18.1]: https://github.com/kryptic-sh/hjkl/compare/v0.18.0...v0.18.1
[0.18.0]: https://github.com/kryptic-sh/hjkl/compare/v0.17.1...v0.18.0
[0.17.1]: https://github.com/kryptic-sh/hjkl/compare/v0.17.0...v0.17.1
[0.17.0]: https://github.com/kryptic-sh/hjkl/compare/v0.16.0...v0.17.0
[0.16.0]: https://github.com/kryptic-sh/hjkl/compare/v0.15.3...v0.16.0
[0.15.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.15.3
[0.15.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.15.2
[0.15.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.15.1
[0.15.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.15.0
[0.14.11]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.11
[0.14.10]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.10
[0.14.9]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.9
[0.14.8]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.8
[0.14.7]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.7
[0.14.6]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.6
[0.14.5]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.5
[0.14.4]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.4
[0.14.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.3
[0.14.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.2
[0.14.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.1
[0.14.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.14.0
[0.13.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.13.0
[0.12.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.12.2
[0.12.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.12.1
[0.12.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.12.0
[0.11.5]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.5
[0.11.4]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.4
[0.11.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.3
[0.11.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.2
[0.11.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.1
[0.11.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.11.0
[0.10.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.10.1
[0.10.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.10.0
[0.9.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.9.3
[0.9.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.9.2
[0.9.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.9.1
[0.9.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.9.0
[0.8.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.8.1
[0.8.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.8.0
[0.7.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.7.0
[0.6.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.6.0
[0.5.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.5.0
[0.4.6]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.6
[0.4.5]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.5
[0.4.4]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.4
[0.4.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.3
[0.4.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.2
[0.4.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.1
[0.4.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.4.0
[0.3.4]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.3.4
[0.3.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.3.3
[0.3.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.3.2
[0.3.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.3.1
[0.3.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.3.0
[0.1.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.1.1
[0.1.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.1.0
[0.0.19]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.19
[0.0.18]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.18
[0.0.17]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.17
[0.0.16]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.16
[0.0.15]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.15
[0.0.14]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.14
[0.0.13]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.13
[0.0.12]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.12
[0.0.11]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.11
[0.0.10]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.10
[0.0.9]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.9
[0.0.8]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.8
[0.0.7]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.7
[0.0.6]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.6
[0.0.5]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.5
[0.0.4]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.4
[0.0.3]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.3
[0.0.2]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.2
[0.0.1]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.1
[0.0.0]: https://github.com/kryptic-sh/hjkl/releases/tag/v0.0.0
