docs/internal/editor-ux-features.md
Records the design decisions, trade-offs, and shipped-vs-planned status of editor UX features that don't warrant their own dedicated doc. Each section is self-contained.
Status legend: SHIPPED = in code and reachable; PARTIAL = core works, gaps remain; PLANNED/DEAD = designed but not built or vestigial.
A Typora-style inline "preview-while-editing" mode for markdown buffers. It is an in-place transform over the editable source (conceals + style overlays + soft-break markers + virtual border lines), not a separate rendered pane. There is no separate preview window — the "Compose/Preview" command names describe one inline mode.
Two components share the name "markdown" but only one is the feature:
pulldown_cmark parser provides markdown rendering for popup
surfaces (LSP hover, signature help), including word-wrap. It is largely
independent of compose mode; recent work has been theme-contrast fixes for
popups.Core integration. A buffer carries a ViewMode of either source or page
view (the latter was previously called compose mode). The view-mode handler maps
both "compose" and "page_view" to page view on the active split's per-buffer
view state, so "toggle all" can target inactive buffers. Buffer info exposes the
view mode, whether the buffer is composing in any split, and a compose width —
the per-split flag exists because conceals are buffer-level but filtered per
split.
Toggle paths (no default keybinding). A toggle-page-view action (with a compose-mode alias) routes to its handler; command palette entries toggle compose for the active file and for all files; menu checkbox state reflects the mode. State persists across sessions via a global compose flag with per-buffer restore on buffer activation.
The seamless-canvas plan is marked "Phase 2 complete"; a later UX evaluation pass found no remaining major-severity issues. Shipped: cursor-aware blur/focus concealment for bold/italic/bold-italic/code/strikethrough, link concealment, box-drawing table rendering with per-row cursor reveal and accumulate-and-grow column alignment, virtual table borders, centered page width, and visual-line navigation through wrapped lines.
The load-bearing design decision (from the async-hook lag class of bugs): the feature originally used the async view-transform token-rewrite pipeline, which caused render flicker on every scroll/edit because the plugin's transformed tokens arrived one frame late. It was rebuilt around synchronous marker-based conceals and soft-breaks computed in the lines-changed hook, eliminating the round-trip. Two supporting techniques: a proactive line refresh in Rust before the async cursor-moved hook, and atomic clear-and-rebuild batching within a single command-drain pass.
Not shipped (plan items still open): OSC-8 clickable links (links are styled but no terminal hyperlink escape), header marker concealment (headings keep their markers), and task-list/code-fence concealment into checkbox glyphs (parsed but not concealed). The remaining-work list (multi-pass transforms, column-guide rendering) is also still pending.
A JSON-driven walkthrough that guides users through a codebase via overlays and a virtual dock buffer. Built and functional, but it landed differently from the design, which proposed a multi-file package.
What actually exists:
$schema points at the new schema path; it omits the optional commit-hash
field.The proposed plugin-API additions all exist — most added end-to-end, one a pre-existing field newly exposed: scroll-to-line-center, get-line-end-position, get-buffer-line-count, and an overlay option to extend an overlay to line end. Each spans an enum or field, a QuickJS method, and dispatch plus handler.
Deviation from doc: the async handlers landed in the dispatch layer, not the command layer as the design's checklist specified. The design's estimate that most of the API was already present held up — only small, established-pattern additions were needed.
A fail-safe wizard for hostile terminals (web SSH, tmux-in-ssh-in-screen, VM consoles) where special keys (Backspace, Home/End, Ctrl/Alt+Arrow) arrive mangled because the Kitty keyboard protocol is unavailable. Fully wired: action → state machine → translator → JSON persistence → input pipeline, with unit tests.
Components:
Key design decision — translate, don't override. The calibration sits in a key-translator layer before keybinding resolution (raw → normalized key → keymap → action), so emacs/vscode keymap customization still composes on top. Overriding raw-key→action directly was rejected because it bypasses the keymap. The wizard UI deliberately uses only lowercase ASCII controls (skip, back, group-skip, abort, save, restart) because Enter/Esc/Ctrl may themselves be broken — and verify-before-save plus always-available abort are explicit goals.
Persistence. The key translator writes a calibration JSON file under the config directory — an array of raw/expected pairs. It is loaded at startup; a missing file yields an empty (no-op) translator. Live translation is applied in the event loop before key handling.
Launch. A calibrate-input action reaches the wizard via a command-palette entry
or direct dispatch; input is routed through a dedicated overlay layer. The
optional --calibrate CLI flag was not implemented.
All modal logic lives in TypeScript with a minimal, mode-agnostic core. The vi-mode plugin is large (many handler registrations); the core has no vi-specific symbols — the plugin drives the global editor mode via a set-editor-mode API.
Core support is generic, not vi-shaped. A per-buffer mode registry holds mode
metadata (read-only flag, text passthrough, binding inheritance) — not the vi
state machine. The "atomic actions" decision is real: a batch execute-actions API
plus operator-specific atomic actions (delete-to-line-start for d0,
yank-to-line-end, etc.) avoid async race conditions for operator+motion combos.
Coverage verified: movement, count prefix, operators (d/c/y), text
objects, visual/visual-line/visual-block, find-char f/t/F/T with ;/,
repeat, and repeat . via last-change capture. The colon-command table has many
entries. Enabled via plugin config auto-start or a toggle command.
Registers and macros are absent from the vi plugin. Worth flagging: a separate, native register-keyed macro system exists — it records actions and codegens an execute-actions block — but it is independent of and not wired into the vi plugin, so the "missing registers and macros" claim is correct in scope.
UI strings are externalized to JSON locales across many languages. The crate is
rust-i18n, and locale JSON bytes are embedded at compile time.
Plugin strings are localized too, via a separate mechanism: per-plugin i18n JSON files sit next to plugins, loaded through register/translate plugin-string helpers (a lock-guarded map with variable interpolation) — independent of the rust-i18n backend.
Locale selection precedence (CLI > config > env): a --locale flag, then the
configured locale, then locale detection from environment variables
(LC_ALL→LC_MESSAGES→LANG, region-aware, e.g. pt_BR→pt-BR, else "en").
DISCREPANCY — the design's i18n note says "compile-time embedding, zero runtime overhead." That is inaccurate. Fresh overrides rust-i18n's default backend with a custom runtime backend. JSON bytes are embedded at compile time, but they are parsed at runtime — the backend lazily parses, flattens, and leaks the JSON on first use per locale. The module's own doc-comment states the intent: it replaces the compile-time macro expansion with runtime JSON parsing to significantly reduce compiler memory usage. So the real trade was lower compile-time memory at the cost of a one-time runtime parse per locale — not "zero runtime overhead." The stale header comment still repeats the old framing and should be corrected.
The menu model carries a locale-independent id (keybinding match) split from the
translatable label (display); a menu item is an untagged enum —
separator / action (with optional checkbox) / submenu / dynamic submenu (sourced,
e.g. themes) / label. The menu context is a string-to-bool map; both when
(enable) and checkbox resolve against it, and it is recomputed each frame. Clicks
hit-test a cached menu layout from the prior render frame; unknown action names
fall back to a generic plugin-action. One expansion path is shared by the TUI
renderer and the web menu view so frontends can't diverge.
Not a separate overlay — it is a mode of the unified Quick Open picker. The
command-palette action is explicitly an alias kept for keymap/plugin
compatibility; it delegates to Quick Open seeded with the ">" prefix. There is
one prompt of the Quick Open type; the leading char routes the mode (> commands,
# buffers, : go-to-line, none = files). The command registry holds built-ins
plus thread-safe plugin commands and a recency history; matching is fuzzy.
Localized command names use a %-prefix convention.
Not a floating overlay — two read-only virtual buffers. The manual content is
bundled at compile time. The help orchestrators insert the static manual, while the
keyboard-shortcuts buffer is dynamically generated from the live keymap so it
reflects the user's actual bindings. Both run in a special buffer mode that binds
q to close and blocks edits, triggered by show-help and show-keyboard-shortcuts
actions. (A richer interactive keybinding editor exists separately.)
Named single-char register marks, position-based (not marker-tracked). A bookmark holds a buffer id and a byte position, keyed in a char-to-bookmark map; jumps clamp the position to the buffer length since a raw byte offset can drift after edits. Actions cover set, jump, clear, and list, plus interactive prompt-set/jump. The jump operation stays on the editor (not the window) because it fires plugin hooks; it switches buffers, force-recenters, and forgets a bookmark whose buffer is gone. Persisted across sessions by file path, not buffer id, and re-resolved on load, dropping bookmarks whose files aren't reopened. State is mid-migration from editor-level to per-window.
Implements the core warning-UX decision faithfully: no auto-opening warning tabs; a tier-1 always-visible status-bar badge plus a tier-2 on-demand view. But the generic-trait popup machinery is largely dead code, and the live actionable UX is routed through LSP instead.
A warning-domain trait exposes id, label, level, popup content, and a has-warnings flag. There are only two built-in implementors — a general domain (from tracing WARN/ERROR) and an LSP domain (from LSP statuses). The registry holds two concrete fields, not a vector of trait objects, and exposes no register/add method.
DISCREPANCY #1 — extensibility overstated. The doc says "LSP, plugins, and config register custom warning handlers." There is no runtime registration API; the trait is object-safe but never used polymorphically. Plugins influence warnings only indirectly through the LSP-status hook (below), not the trait. The warning-domain set is fixed, not an open extensibility API.
Tier 1 — badge (SHIPPED). Gated on a config flag. Two inputs: a general-warning count badge, and LSP severity applied as a colored background on the existing LSP status segment (the LSP domain's label returns empty by design). The count is driven by draining a notification channel each tick.
Tier 2 — on-demand "actionable solutions" (PARTIAL).
DISCREPANCY #2. The trait's popup-content path, with warning-action install commands, has no callers — it is vestigial. The general-warning click path does not show a popup; it opens the log file read-only. The real actionable popup is the separate LSP-status popup: clicking the LSP segment fires an LSP-status-clicked hook, and plugins inject fix-it rows via an LSP-menu-contributions API (e.g. "Copy: rustup component add rust-analyzer", "Disable Rust LSP"). So the two-tier actionable-popup vision is realized for LSP warnings only, not general warnings.
status_log vs warning_log. The warning log is a tracing layer capturing WARN+ERROR to a file with time/count dedup and a notification channel — it drives the badge. The status log is a tracing layer capturing only status-targeted events — no dedup, no channel, no badge; it is purely the transient status-message history ("notification log"), opened via a show-status-log action or by clicking the status-message segment.
LSP install-helper plugins (SHIPPED, broader than documented). The doc names only Python, Rust, and TypeScript; in reality many per-language LSP plugins ship (Python, Rust, TypeScript, Go, clangd, Bash, Java, Ruby, PHP, Zig, and more), each contributing copy-install and disable actions to the LSP popup. DISCREPANCY #3 — doc undersells breadth.