diff --git a/Cargo.lock b/Cargo.lock index 15707b8..9ed454f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2558,6 +2558,7 @@ dependencies = [ "tree-sitter-cuda", "tree-sitter-go", "tree-sitter-javascript", + "tree-sitter-json", "tree-sitter-lua", "tree-sitter-make", "tree-sitter-md", @@ -2565,7 +2566,9 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "tree-sitter-yaml", "tree-sitter-zig", + "unicode-segmentation", "unicode-width", ] @@ -3807,6 +3810,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" @@ -3883,6 +3896,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-zig" version = "1.1.2" diff --git a/Cargo.toml b/Cargo.toml index 98fec84..166751e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"] crossterm = "0.28" thiserror = { workspace = true } unicode-width = "0.2" +unicode-segmentation = "1" # Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex` # matches over rope-snapshot bytes and yields byte offsets directly. # Already in the lockfile transitively; promoted to a direct dependency. @@ -196,6 +197,13 @@ tree-sitter-javascript = "0.25" tree-sitter-typescript = "0.23" tree-sitter-toml-ng = "0.7" tree-sitter-zig = "1.1" +# JSON + YAML — config formats + the honest gate on the Jupyter path +# (JSON). Both are ABI-current (`LANGUAGE: LanguageFn` via +# `tree-sitter-language`), NOT a `tree-sitter ^0.20` fork. Registering +# yaml also lights up markdown `---` frontmatter through the #122 +# injection engine (the block injection query already sets yaml for it). +tree-sitter-json = "0.24" +tree-sitter-yaml = "0.7" # T M9.7: markdown grammar so prompt result buffers with # `_meta.format = "markdown"` get structured highlighting through the # same M4 path as rust/lua — no special-case painter in Lua. diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eb4f1e4..251f4b7 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -200,6 +200,62 @@ pmacs.lsp.config.zig = pmacs.lsp.config.zig or { args = {}, } +-- JSON via the VS Code JSON server, binary `vscode-json-language-server`. +-- It is a PUSH-model server: it reads config from +-- `workspace/didChangeConfiguration` (the daemon now sends one after +-- `initialized`) and does NOT issue `workspace/configuration` pulls, so +-- without that push these settings would be inert. `json.validate.enable` +-- is set explicitly true — the server treats a MISSING value as false, so +-- an empty `json = {}` would silently disable validation. Schema +-- retrieval performs NETWORK ACCESS for remote `$schema` URLs (left +-- enabled; `handledSchemaProtocols = {"file"}` would disable it but break +-- remote schemas without a `vscode/content` impl). Note: the server does +-- NOT auto-associate `package.json`/`tsconfig.json` — it starts with empty +-- contributions; explicit `$schema` refs or configured `json.schemas` / +-- a `json/schemaAssociations` push (not implemented) are required. +-- Provider: pin `@t1ckbase/vscode-langservers-extracted@2.0.2` +-- (`npm install -g @t1ckbase/vscode-langservers-extracted@2.0.2`). +-- Its published payload bundles the JSON server from VS Code 1.129.0, +-- preserves this command name, and was live-smoked through initialize → +-- config push → invalid-JSON diagnostic → shutdown. The older unscoped +-- package is stale and the current `@zed-industries` payload has a broken +-- JSON launcher; neither is the recommended provider. +pmacs.lsp.config.json = pmacs.lsp.config.json or { + command = "vscode-json-language-server", + args = { "--stdio" }, + settings = { + json = { validate = { enable = true } }, + http = {}, + }, +} + +-- YAML via Red Hat `yaml-language-server`. On +-- `workspace/didChangeConfiguration` (now pushed after `initialized`) it +-- reads the `yaml`, `http`, `[yaml]`, `editor`, and `files` sections — all +-- ship present-not-null (empty ⇒ server defaults). SchemaStore / remote +-- schema retrieval performs NETWORK ACCESS by default. The standalone +-- server does not upload telemetry itself — it emits `telemetry/event` +-- notifications to its client, and pmacs has no telemetry uploader, so a +-- `redhat.telemetry` setting would be inert and is not shipped. Sections +-- live-observed with Red Hat `yaml-language-server@1.24.0`: its initial +-- pull requests exactly those five sections, and opening a YAML document +-- requests a second scoped `[yaml]` section. The standalone smoke reached +-- a real syntax diagnostic and clean shutdown. The PATH-gated pmacs +-- acceptance also proves auto-attach, initialization, config pulls, a +-- syntax diagnostic, and continued server liveness with both catalogs +-- disabled for network-free determinism. +pmacs.lsp.config.yaml = pmacs.lsp.config.yaml or { + command = "yaml-language-server", + args = { "--stdio" }, + settings = { + yaml = {}, + http = {}, + ["[yaml]"] = {}, + editor = {}, + files = {}, + }, +} + -- LSP-side extension → language map, deliberately independent of the -- tree-sitter detection in `pmacs.parse`. Consulted only when -- `pmacs.parse.language_for_path` finds nothing (an extension with a @@ -267,6 +323,11 @@ pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml" -- Zig (zls). `.zon` is Zig Object Notation, handled by the same server. pmacs.lsp.filetypes.zig = pmacs.lsp.filetypes.zig or "zig" pmacs.lsp.filetypes.zon = pmacs.lsp.filetypes.zon or "zig" +-- JSON / YAML. Both ship grammars, so `language_for_path` already resolves +-- these and the map is the stable-id fallback (same role as `lua`/`cuda`). +pmacs.lsp.filetypes.json = pmacs.lsp.filetypes.json or "json" +pmacs.lsp.filetypes.yaml = pmacs.lsp.filetypes.yaml or "yaml" +pmacs.lsp.filetypes.yml = pmacs.lsp.filetypes.yml or "yaml" -- Per-buffer attachment record: { language, server, uri, version }. -- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh @@ -730,6 +791,22 @@ function pmacs.lsp.active_attachment() return attachments[tostring(buf)] end +-- Arc 4 stage 3: pure modeline projection. This reads the private +-- per-buffer attachment map directly so passive split windows report their +-- own buffer instead of the focused window. It never attaches, flushes +-- didChange, or issues a request. +pmacs.statusline.register { + name = "lsp", + side = "right", + priority = 0, + face = "ui.modeline.lsp", + fn = function(ctx) + local rec = attachments[tostring(ctx.buffer)] + if not rec then return nil end + return "LSP:" .. pmacs.lsp.modeline_label(rec.server) + end, +} + -- Flushing variant for request-issuing callers outside this file -- (Q#C8): when the active buffer already has a server attached, -- flush any debounced didChange first and return the record, so the diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index c1ca3f6..b6c9aee 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -114,64 +114,85 @@ commands, read `docs/active-work.md` immediately after this file. `pmacs.editor.take_typed_edit()` (buffer-revision postcondition, Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`, `PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`. -- **Themes (Arc 4) stage 1 LANDED — #120 merged after 5 review - rounds** (`docs/theme-faces-framing.md` rev 9 is the full record): - named UI faces as reserved `ui`/`ui.*` theme entries (12-face - inventory, owns-surface-within-mask, masks identical on both - frontends); `Theme::face()` walk (`None` when unset); transactional - mutators with split syntax/face epochs (fixed the pre-existing - mid-session `theme.set` span staleness); `ThemeFacts` channel (v16, - one authoritative send per attachment; v15 peers excluded incl. the - `FileStyleSummary` face-leak side channel). Review rounds hardened - substrate beyond faces: the **snapshot/baseline reset contract** - (`on_buffer_snapshot_sent` daemon-side + the GPU arm's symmetric - search/menu/status clears; minibuffer, gutter mode, `ThemeFacts` - survive both sides) and the **store-sourced diag-count freeze** - (per-URI severity totals in `DiagnosticStore`, O(1), survive - `mark_stale`). -- **Themes (Arc 4) stage 2 LANDED — #124 merged after the complete - behavioral review** (`docs/gpu-set-font-framing.md` rev 5): - `pmacs.gpu.set_font { family?, size? }` is a live global preference; - authoritative bufferless `FontFacts` is gated at protocol v17. - GPU family resolution is frontend-local and fail-closed to a - sanitized monospace default across normal/bold/italic queries; - metrics for all seven glyphon buffers derive atomically from one - logical-pixel size. The visual-run caret substrate normalizes - source bytes through adornments and shaped clusters (including - combining sequences/ligatures), preserves deliberately scrolled-away - viewports, and reflows on font, gutter, minimap, text, CRDT, resize, - and snapshot geometry changes. Test fixtures enter fontdb before - `FontSystem` construction; alternate advances are measured across - complete shaped runs, not sampled glyphs. -- **Themes (Arc 4) stage 3 LANDED — #125; ARC 4 COMPLETE** - (`docs/statusline-segments-framing.md` rev 3). Strict composable - `pmacs.statusline` providers evaluate per frontend/window through a - borrow-released three-phase transaction with per-context failure - latches. TUI composition is grapheme/display-width correct; the pure - built-in LSP provider proves passive-window context. Protocol v18 - carries complete `StatuslineSegments` replacements and dynamic - modeline faces; snapshot baselines reset symmetrically. GPU validates - untrusted payloads atomically, shapes rich runs without wrapping, and - right-pins the protected suffix under narrow clipping. Review - hardening pins fixed-face sortedness, retains flattened provider - tracebacks, and names unavailable layout contexts accurately. -- **Vterm Stage 1 terminal core LANDED — #126** - (`docs/vterm-framing.md` rev 5). `AnsiParserProfile::{LineOriented, - FullScreen}` preserves compile/REPL behavior while terminal PTYs emit the - full cursor/mode/device operation set. `src/terminal/{screen,input,session}.rs` - owns a bounded terminal screen, encoders, and the transactional lifecycle - registry; one pathless read-only identity buffer anchors each private - process/screen. Review hardening added IND/NEL/RI, `TERM=xterm-256color`, - portable shutdown liveness, custom-tab-stop preservation, control-free - cells, and button-preserving SGR mouse release. Final gates: 1,661 default + - 1,837 CRDT library tests; 9 default + 10 CRDT Vterm acceptance; 114 M4; 109 - required GPU; workspace 2,769 across 79 suites; CI green. This is headless: - Vterm Stage 2 TUI/Lua and Stage 3 protocol/GPU start as separate PRs from - post-#126 `main`. +- **Themes (Arc 4) stages 1–3 LANDED; Arc 4 COMPLETE ON `main`.** + - Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces + as reserved `ui`/`ui.*` theme entries; transactional split + syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline + symmetry; store-sourced diagnostic-count freeze. + - Stage 2 (#124, `docs/gpu-set-font-framing.md` rev 5): + `pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`; + frontend-local family resolution, live font reload/reflow, and + visual-run caret geometry. + - Stage 3 (#125, `statusline-segments`, + `docs/statusline-segments-framing.md` rev 3): composable strict + `pmacs.statusline` providers; borrow-released per-window evaluation + with failure latches; legacy-preserving TUI composition; a pure + built-in LSP provider; dynamic modeline faces; protocol-v18 + `StatuslineSegments`; authoritative-empty/snapshot symmetry; and + atomic GPU validation, face resolution, shaping, clipping, and + cache invalidation. Acceptance 1-27 is implemented. Final gates: + Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default + + 8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation + workspace sweep 2,718 passed across 78 suites (19 ignored, + `basedpyright` filtered); `git diff --check` clean. Stage 3 landed + as #125 and completed Arc 4 on `main`. +- **Vterm Stage 1 terminal core LANDED ON `main` — #126** + (`docs/vterm-framing.md` rev 5; merge `643d1e1`). + - Implementation commits: `bbc1f33` (Stage 1), `962944b` (Darwin signal + normalization), first-review fixes `f0a235f`, `28f2e6c`, `bf972a7`, and + second-review hardening `9797ada`; reviewed feature head `fc4e0ce` merged + through PR #126, . + - `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL + behavior while terminal PTYs emit the full cursor/mode/device operation + set. `src/terminal/{screen,input,session}.rs` owns the state machine, + encoders, and lifecycle registry. + - Public session seam: owned strict `TerminalSpec`; owned + `TerminalSnapshot`; `TerminalProcessState`; and + `SharedTerminalManager = Rc>` with + `open/is_terminal/process_id/snapshot/tick/send/resize/terminate/prune/ + shutdown`. Stage 1 snapshots are context-free; Stage 2 adds per-view + state without a second screen. + - `EditorState` tick order is supervisor → terminal-owned PID drain/prune → + `process.after-tick`. Terminal IDs are not exposed through + `pmacs.process`; ordinary Lua/LSP/MCP ownership is unchanged. Terminal + identity buffers are pathless, clean, empty, round-trip, and guarded + read-only at every rope/CRDT/history mutation boundary. + - Acceptance 1–14 is mapped in the framing. The real PTY bite splits + ESC/CSI writes, observes alternate-screen cursor addressing, blocks and + resumes through raw `send`, restores the main screen, and pins final + output before exact PID/outcome annotation. One-row annotation visibility, + TERM-ignoring shutdown, spawn rollback, buffer-kill prune, and immutable + empty CRDT bootstrap are pinned. + - Review round 1 added typed IND/NEL/RI with margin-correct screen behavior, + defaults absent `TERM` to `xterm-256color`, makes shutdown liveness + acceptance portable with `kill(pid, 0)`, and preserves custom tab stops on + resize. Review round 2 rejects C0/C1 controls before they enter screen + cells, preserves the released button code in SGR mouse reports, removes + dead screen paths, and clears stale round-trip state during prune. Stage 2 + must uniquify default terminal buffer names. + - Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, + xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and + CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit + post-arc deferrals in the framing. + - Final from-start rerun after review round 2: Clippy clean; 1,661 default + + 1,837 CRDT library tests (3 ignored each); 9 default + 10 CRDT vterm + acceptance; M4 114 passed (3 ignored, 1 filtered); required GPU 109; + workspace 2,769 passed across 79 suites (19 ignored, 1 filtered); diff + check clean. `scripts/bite HEAD^ src/terminal/screen.rs --test + vterm_stage1_acceptance terminal_cells_reject_child_control_characters` + is a clean behavioral bite. The parser dispatch has its independent clean + behavioral bite; the original `main`/crate-root bite remains explicitly + weaker compile-time API evidence. + - Stage 2 reviews require a durable focus/input resize owner, owning + `FrontendId` for the global `C-c` continuation, and local clipboard/BEL + signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, + authenticated source routing, protocol-owned wire types/limits, and a + deliberate complete-frame limit decision: 16 MiB is insufficient; use a + measured legal-worst cap or aggregate bound, never silent chunking. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is - preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is - stale and must be repeated before implementation. No PR or - implementation is active. + preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale + and must be repeated before implementation. No PR or implementation is + active. - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **Arc 1 (LSP utility surface) COMPLETE** — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus diff --git a/docs/json-yaml-framing.md b/docs/json-yaml-framing.md new file mode 100644 index 0000000..e212bc1 --- /dev/null +++ b/docs/json-yaml-framing.md @@ -0,0 +1,220 @@ +# JSON + YAML grammars — framing (side quest, highlight family) + +**Revision 4 — 2026-07-21. Status: PR #123 open and awaiting review; +the public and checkpoint branches are at fully gated `5c202c5`, rebased +onto `main` `f8096ff`.** +The JSON provider and Red Hat YAML 1.24.0 have each passed their +PATH-gated pmacs acceptance, in addition to the deterministic fake-server +config-push proof and the YAML standalone protocol smoke. + +**Intent.** Add `tree-sitter-json` and `tree-sitter-yaml` grammars (plus +their language servers) to the bundle. Two config formats that pmacs +currently renders as plain text, and — the reason this is the natural +next side quest — the **honest gate on the Jupyter `.ipynb` path** (JSON) +and an **immediate payoff from the injection engine just shipped (#122)**: +the markdown block grammar's `injections.scm` already sets +`injection.language "yaml"` for `---` frontmatter and `"toml"` for `+++` +frontmatter, so registering YAML lights up YAML frontmatter highlighting +with zero extra wiring (TOML frontmatter already works — `toml` landed in +#118 and injections in #122). This is a mostly-additive grammar-gap-style +change, following the #118 pattern, with the frontmatter/fence synergy as +the demonstrable headline. + +--- + +## Ground truth (as of `main` @ `56eb67e`, #121) + +- **Adding a grammar** is a one-line `LanguageEntry` in + `crate::syntax::BUILTIN_LANGUAGES` (`name`, `extensions`, `loader`, + `highlights_query`, `injections_query`) + a `tree-sitter-foo` dep. The + Lua `buffer.after-load` path picks it up automatically; detection is + extension → LSP filetype → filename → shebang + (`resolve_active_language`). +- **Grammar name MUST equal the `pmacs.lsp.config.` key** — grammar + detection wins over the filetype map, so the name it resolves is the id + the LSP client keys off (the #118 invariant; there's an acceptance test + that pins every grammar-gap language to its config key). +- **LSP configs** are `pmacs.lsp.config. = … or { command, args, + [settings|init_options] }` (`builtin/runtime/lsp.lua`); no json/yaml + config today. `pmacs.lsp.filetypes` is the LSP-only extension fallback + (consulted only when `language_for_path` misses). +- **Injection synergy (#122).** The bundled `tree_sitter_md:: + INJECTION_QUERY_BLOCK` contains: + - `((minus_metadata) @injection.content (#set! injection.language + "yaml"))` — `---`-fenced frontmatter, + - `((plus_metadata) @injection.content (#set! injection.language + "toml"))` — `+++`-fenced frontmatter, + - fenced code blocks via the dynamic info-string. + So a registered `yaml` grammar is injected into markdown frontmatter + automatically, and ` ```json `/` ```yaml `/` ```yml ` fences resolve + (`yml`→yaml is already in `default_injection_aliases`; `json` is the + bundled name). + +**Confirmed crate facts** (probed against the registry + a build under +tree-sitter 0.26): + +1. `tree-sitter-json` **0.24.8** — `pub const LANGUAGE: LanguageFn` (via + `tree-sitter-language`, the modern shared ABI) + `HIGHLIGHTS_QUERY`. + Compiles and links under our tree-sitter 0.26. No `INJECTIONS_QUERY` + (JSON embeds nothing). +2. `tree-sitter-yaml` **0.7.2** — same shape (`LANGUAGE: LanguageFn`, + `HIGHLIGHTS_QUERY`, `tree-sitter-language` dep). Compiles under 0.26. + No `INJECTIONS_QUERY`. +3. Both are the ABI-current crates — **not** a `tree-sitter ^0.20` fork + (the dockerfile trap from #118). A single build confirmed link + + compile; runtime `set_language` is pinned by the ABI acceptance test. + +--- + +## Decisions + +### Q#JY1 — Two `LanguageEntry`s, self-contained highlights, no injections + +Add `json` and `yaml` to `BUILTIN_LANGUAGES`, each +`highlights_query: &[…::HIGHLIGHTS_QUERY]` (self-contained, no +`; inherits:` delta), `injections_query: &[]`. Extensions: + +- **json:** `.json`. (`.jsonc`/`.json5` — comment/trailing-comma variants + the plain JSON grammar rejects — are **deferred**; a `.jsonc` grammar + or a lenient mode is a separate call.) +- **yaml:** `.yaml`, `.yml`. + +Root kinds (pinned by the ABI test): json `document`, yaml `stream`. + +### Q#JY2 — LSP configs: `vscode-json-language-server` + `yaml-language-server` + +- **json:** binary `vscode-json-language-server --stdio` (the VS Code + JSON server). It is **push-model**: it reads config from + `workspace/didChangeConfiguration` and does **not** issue + `workspace/configuration` pulls — so pmacs, which previously only + *answered* pulls, must now also **push** a `didChangeConfiguration` + after `initialized` (a general LSP-client fix in `src/lsp.rs`; pull + servers ignore it). `json.validate.enable` is set **explicitly true** + — a missing value reads as false and silently disables validation, so + an empty `json = {}` is wrong. The server does **not** auto-associate + `package.json`/`tsconfig.json` (it starts with empty contributions); + explicit `$schema` refs or configured `json.schemas` / a + `json/schemaAssociations` push (not implemented) are required. Schema + retrieval performs **network access** for remote `$schema` URLs, left + enabled (`handledSchemaProtocols = {"file"}` would disable it but break + remote schemas without a `vscode/content` impl). **Provider:** pin + `@t1ckbase/vscode-langservers-extracted@2.0.2` + (`npm install -g @t1ckbase/vscode-langservers-extracted@2.0.2`). Its + published payload bundles the JSON server from VS Code 1.129.0, + preserves the `vscode-json-language-server` command, and was + live-smoked through initialize → config push → invalid-JSON diagnostic + → shutdown. The unscoped package is stale; the current + `@zed-industries` payload has a broken JSON launcher, so neither is the + recommended provider. +- **yaml:** `yaml-language-server --stdio` (Red Hat). Its settings handler + reads the sections **`yaml`, `http`, `[yaml]`, `editor`, `files`** (via + `didChangeConfiguration` / pulls) — all ship present-not-null. It does + **not** upload telemetry itself (it emits `telemetry/event` to the + client; pmacs has no uploader), so a `redhat.telemetry` setting is inert + and is not shipped. SchemaStore / remote schema retrieval performs + **network access** by default. + +Both servers stay **external** (installed by the user), adding **no +licensing payload** to pmacs; if either is ever bundled, retain its MIT + +dependency notices. The exact sections are **pinned in the config + a +test** (not merely "some non-nil table exists"). The pinned JSON +provider was installed into an isolated temporary prefix and +live-smoked through pmacs. Red Hat `yaml-language-server@1.24.0` was +also installed in an isolated prefix and live-smoked over stdio: its +initial configuration pull was exactly `yaml`, `http`, `[yaml]`, +`editor`, `files`; opening the document caused a second scoped +`[yaml]` pull; invalid YAML produced a parser diagnostic; shutdown was +clean. Both providers have also passed their PATH-gated pmacs acceptance. +Config-push delivery is proven deterministically through the fake server's +config sink. Servers activate only if installed; the grammar is the +always-on value. + +### Q#JY3 — Filetype fallback + alias entries + +Add `pmacs.lsp.filetypes` entries (`json`→json, `yaml`/`yml`→yaml) as the +stable-id fallback (grammar detection wins in practice, same role as the +`cuda`/`lua` entries). `default_injection_aliases` already has `yml`→yaml; +`json`/`yaml` are bundled names needing no alias. Special *filenames* +(`.prettierrc`, `docker-compose.yml` is already `.yml`, extensionless +CI/config yaml) are **deferred** to the filename map as a follow-up. + +### Q#JY4 — Frontmatter/fence highlighting is the headline, and it's free + +No new injection wiring: registering `yaml` makes the existing markdown +`minus_metadata`→yaml injection resolve, and ` ```json `/` ```yaml ` +fences resolve through the #122 engine. Acceptance proves both end to end +(this is the demonstrable payoff and the tie-back to injections). + +--- + +## Bets + +1. The two crates are ABI-current and drop in like the #118 grammar-gap + languages — verified by a build; the ABI test is the runtime pin. +2. The frontmatter/fence synergy needs zero engine changes — it falls out + of #122 + the markdown injection query. +3. The two configuration models are now observed: JSON consumes the + pushed full settings object; YAML 1.24.0 pulls the five documented + sections plus a document-scoped `[yaml]` request. The remaining bet + was that pmacs answers the real YAML server correctly end to end; + the PATH-gated acceptance now proves that against version 1.24.0. + +## Deferred (named) + +- `.jsonc` / `.json5` (comments / trailing commas) — needs a lenient + grammar or variant entry. +- Special-filename detection for extensionless config files (`.prettierrc`, + CI yaml) via the filename map. +- JSON **schema** wiring (custom `json.schemas` / `yaml.schemas` settings) + beyond the servers' built-in schema stores. +- The Jupyter `.ipynb` arc itself (JSON is its prerequisite, not its + delivery). + +## Acceptance + +1. `builtin_languages_include_json_and_yaml` — entries present, claim + their extensions, ship non-empty highlights. +2. `json_grammar_loads_and_parses` — ABI: `set_language` + parse a JSON + object; root `document`, no error (the runtime ABI pin). +3. `yaml_grammar_loads_and_parses` — ABI: parse a YAML mapping; root + `stream`, no error. +4. `json_yaml_highlights_compile` — both highlights queries compile and + resolve several capture classes. +5. `language_for_path_resolves_json_yaml` — `.json`→json, `.yaml`/`.yml`→ + yaml. +6. `json_yaml_align_with_lsp_configs` — grammar name == the + `pmacs.lsp.config.` key (the #118 invariant). +7. **`yaml_frontmatter_injects_in_markdown`** — a markdown doc with a + `---\nkey: val\n---` frontmatter yields a `yaml` child layer that + highlights; **the headline synergy with #122**. +8. `json_fence_injects_in_markdown` — a ` ```json ` fence yields a `json` + child layer. +9. `m4_json_yaml_lsp_configs_pin_command_and_sections` — the configs + pin the binary + `json.validate.enable = true` + the exact section + sets (json: `json`,`http`; yaml: `yaml`,`http`,`[yaml]`,`editor`, + `files`; no inert `redhat.telemetry`) — pinned, not merely non-nil. +10. `m4_5_initial_config_pushed_via_did_change_configuration` — the + daemon PUSHES `workspace/didChangeConfiguration` after `initialized` + (the push-model delivery path), verified through the fake server's + config sink. Without it, push-only servers' settings are inert. +11. `m4_real_json_provider_receives_config_and_reports_diagnostics` — + PATH-gated live smoke for the pinned provider: initialize through + pmacs, receive the pushed default config, open invalid JSON, and + publish a syntax diagnostic. Skips when the binary is absent. +12. `m4_real_yaml_provider_pulls_config_and_reports_diagnostics` — + PATH-gated live smoke for Red Hat `yaml-language-server@1.24.0`: + auto-attach through pmacs, disable SchemaStore and Kubernetes CRD + catalog network access for determinism, reach initialized, open + invalid YAML, publish a diagnostic, and remain alive. + +## Risks / interactions + +- **LSP configuration** (Q#JY2) — JSON push, YAML standalone pulls, and + the real YAML-through-pmacs path are observed. Both live provider + tests remain PATH-gated, so release verification must put the pinned + binaries on PATH rather than accepting their skip paths. +- **Themes / injections** — untouched. This is pure grammar+detection + addition; it consumes the #122 engine, doesn't change it. No protocol + bump. +- **`.yml` vs `.yaml`** — both map to `yaml`; no collision with any + existing entry. diff --git a/docs/package-author-guide.md b/docs/package-author-guide.md index e43de11..17fc538 100644 --- a/docs/package-author-guide.md +++ b/docs/package-author-guide.md @@ -106,8 +106,8 @@ Package entry chunks run during package load, including audit and headless load paths. Keep top-level code limited to registration and state setup. Surfaces installed by the base Lua host are available there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`, -`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`, -`pmacs.now_ms`, and the standard Lua libraries. +`pmacs.hook`, `pmacs.statusline`, `pmacs.describe`, `pmacs.help`, +`pmacs.attach`, `pmacs.now_ms`, and the standard Lua libraries. Editor-state surfaces are available once the editor bridge is installed: command bodies invoked by pmacs, main-thread hooks fired by @@ -414,6 +414,55 @@ to work whenever `define` works (parity), and packages need to call it from `on_unload` hooks that fire on post-init `reload(name)` calls. +### Statusline providers: register for every window, unregister on unload + +`pmacs.statusline.register` installs a live provider and returns an +opaque handle. Registration accepts a strict table with only `name`, +`side`, `priority`, `face`, and `fn`: `name` is a non-empty display +label, `side` is `"left"` or `"right"`, `priority` defaults to `0`, +`face` defaults to `"ui.modeline"` and otherwise must be a +`ui.modeline.*` face, and `fn` is the callback. + +Providers are evaluated once for each rendered window context, not once +for the editor's active buffer. Always read the callback's `ctx.buffer` +handle; a split's passive window can display a different buffer: + +```lua +local segment = pmacs.statusline.register { + name = "mypkg-buffer", + side = "left", + priority = 20, + face = "ui.modeline.mypkg", + fn = function(ctx) + -- ctx.frontend and ctx.window are integer identities. + -- ctx.buffer is this window's Buffer handle, even when passive. + local marker = ctx.active and "*" or "" + return marker .. ctx.buffer:name() + end, +} + +pmacs.packages.on_unload(function() + pmacs.statusline.unregister(segment) -- idempotent; false if already gone +end) +``` + +The callback returns a string, `nil`, or `""`; the latter two mean no +segment. Output is one line (the first newline ends it), control +characters become spaces, and an over-limit result is omitted as a +provider failure. Failures are reported once per provider/window +context until that context succeeds or the provider is disabled and +re-enabled. + +Ordering is deterministic: left providers use priority descending, +then registration order; right providers use priority ascending, then +registration order. `pmacs.statusline.providers()` returns fresh +metadata tables. `set_priority(handle, integer)` and +`set_enabled(handle, boolean)` return `false` for a stale handle and +change live output immediately. `unregister(handle)` is idempotent and +returns whether it removed a live provider. Registering in package +top-level code without the matching `on_unload` cleanup leaks the old +provider across `reload(name)`. + ### `pmacs.fs.*` — worker-dispatched filesystem primitives The four async fs operations packages need without reaching for diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 66c2baf..f2fd6eb 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -21,6 +21,12 @@ against this design: - **M11.5** — the headless `SemanticClient` glue + reconstruction- equivalence and end-to-end tests. +- **Themes Arc 4 stage 3 (protocol v18)** — composable Lua statusline + providers project complete ordered left/right text+face runs through + `StatuslineSegments`. The daemon evaluates one callback per matching + window context; the frontend owns shaping, separators, clipping, and + all pixel placement. + Post-M11 producer arc (the LSP feature arc landed the missing data sources, so the "wire in when those features land" promise came due): @@ -97,36 +103,36 @@ prohibited by this contract, not merely discouraged. ## Composition with v1.0 primitives -The semantic projection ships **no text**. A `semantic_render` +The semantic projection ships **no document text**. A `semantic_render` session is required to also be a text replica — it holds the rope -locally via the existing `crdt_replica` machinery -(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The -semantic frame is purely the *interpretation layer* over a buffer -the frontend already has: styling and decoration keyed by byte -range. This mirrors how v1.0 already coupled `multi_frontend` -and `crdt_replica`, and it keeps the new wire tiny — single-digit -KB for a screenful, diffable at span granularity. +locally via the existing `crdt_replica` machinery (`BufferSnapshot` to +bootstrap, `CrdtOp` to stay live). Styling and decorations are purely +interpretation over bytes the frontend already holds. Protocol v18's +one deliberate text-bearing exception is `StatuslineSegments`: bounded +one-line chrome text that is not document content. This preserves the +semantics-down model while letting daemon-owned Lua state contribute to +frontend-local modeline layout. Consequently the new surface is small. Cursor reuses the existing `InstanceMessage::CursorByte` (authoritative cursor as a buffer offset — added for CRDT optimistic-apply, exactly what a layout-local frontend consumes). Peer cursors reuse the existing `PresenceUpdate`. Edits and local cursor travel the existing -`FrontendEvent::CrdtOp` / presence path. The genuinely new wire -is: one capability bit, ~five instance→frontend interpretation -variants, and one frontend→instance `Viewport` variant. +`FrontendEvent::CrdtOp` / presence path. Later interpretation and +chrome families append under explicit protocol-version gates; v18 adds +only `StatuslineSegments` to the v17 shape. **`BufferSnapshot` resets buffer-scoped interpretation state.** A frontend receiving a snapshot drops everything it holds for the named buffer — spans, decorations, adornments, minimap summary, completion popup, search and menu prompts (which also gate the -frontend's key/pointer interception), and status facts — and -rebuilds from the frames that follow; the instance mirrors this by -invalidating its per-buffer emission baselines whenever it writes a -snapshot, so the frontend's post-snapshot viewport declaration -receives authoritative re-sends even when nothing changed -daemon-side (the unchanged-generation A → B → A revisit). Bufferless -facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and +frontend's key/pointer interception), status facts, and statusline +segments — and rebuilds from the frames that follow; the instance +mirrors this by invalidating its per-buffer emission baselines whenever +it writes a snapshot. The frontend's post-snapshot viewport declaration +therefore receives authoritative re-sends even when nothing changed +daemon-side (the unchanged-generation A → B → A revisit). +Bufferless facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and per-frontend state (the gutter mode) survive snapshots on both sides (frontend-locally the normalized code scroll — a caret-follow view residual — is buffer-scoped and resets, while the resolved font and @@ -238,13 +244,19 @@ ResourceOffer { /// declaration; cached-compare suppressed thereafter, so an /// unthemed session pays one small message and nothing more. /// Resolution (the `ui.*` dotted-prefix inheritance walk) happens -/// daemon-side over the stage-1 face inventory — frontends do -/// exact-name lookup only, and apply each face within its -/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5: -/// a set face owns its surface; `Default` components mean the -/// frontend's plain rendering; out-of-mask components are never -/// read). Daemon-gated `>= 16`; appended as the FINAL variant — -/// postcard discriminants are ordinal. +/// daemon-side; frontends do exact-name lookup only and apply each face +/// within its stage-1 component mask +/// (`docs/theme-faces-framing.md` Q#TH3/Q#TH5: a set face owns its +/// surface; `Default` components mean the frontend's plain rendering; +/// out-of-mask components are never read). +/// +/// At protocol v18 the resolved inventory also includes every enabled +/// statusline provider's exact `ui.modeline.*` face name. Registration, +/// unregister, and enable changes invalidate that inventory; priority +/// changes do not. v16/v17 peers retain only the fixed stage-1 set and +/// never execute statusline providers. Daemon-gated `>= 16`; its +/// postcard placement remains before `FontFacts` and +/// `StatuslineSegments`. ThemeFacts { faces: Vec, // { name: String, style: Style }, sorted by name }, @@ -263,19 +275,59 @@ ThemeFacts { /// owns every metric consequence; sizes travel as integer /// hundredths of a logical pixel (1600 = 16.0, validated to /// 600..=7200 on BOTH sides — the receiver fails closed on -/// out-of-range wire values). Daemon-gated `>= 17`; appended as -/// the FINAL variant — postcard discriminants are ordinal, and the -/// ThemeFacts byte pin above guards this placement. +/// out-of-range wire values). Daemon-gated `>= 17`; v18's +/// `StatuslineSegments` is appended after it because postcard +/// discriminants are ordinal. FontFacts { family: Option, // None = the frontend's default family size_centi_px: Option, // None = the frontend's default size }, + +/// One daemon-evaluated statusline run. `text` is non-empty, +/// control-free UTF-8; `face` is `ui.modeline` or a valid +/// `ui.modeline.*` name resolved through `ThemeFacts`. +StatuslineSegment { + text: String, + face: String, +}, + +/// Themes Arc 4 stage 3 (protocol v18). A complete replacement for one +/// buffer's custom modeline runs, never a patch. The left vector is in +/// display order (priority descending, registration id ascending); +/// right is in display order from the center toward the protected +/// suffix (priority ascending, registration id ascending). +StatuslineSegments { + buffer_id: BufferId, + left: Vec, + right: Vec, +}, ``` -Each family member diffs against the previous frame the same way -`CellDelta` does today — the instance ships changed spans, not -full re-sends, scoped to the viewport range the frontend last -declared. +`StyleSpans` retains its dirty-segment diffing. `StatuslineSegments` +uses a complete-payload baseline instead: first sight of a buffer sends +one authoritative replacement, including `left=[]`, `right=[]`; a +byte-identical later evaluation is silent. Authoritative empty is data, +not "no message": it clears a prior payload after unregister, disable, +provider failure, or an evaluation invalidated by callback mutation. +Nil and empty-string provider returns are simply absent runs. + +The v18 producer evaluates only after a matching viewport declaration +for the semantic session's active daemon window. Provider execution is +version-gated before evaluation, so a v17 peer incurs no callbacks and +receives neither this variant nor provider-only dynamic `ThemeFacts` +entries. The receiver validates a whole message atomically using the +shared protocol limits (64 runs, 1024 bytes per run, 64 KiB aggregate, +256-byte valid face names); malformed input leaves the prior payload +unchanged. + +`BufferSnapshot` clears the named buffer's frontend mirror immediately +and drops the producer baseline. The unchanged-generation A → B → A +return therefore remains empty until the authoritative re-send arrives, +then restores the exact prior runs. The instance owns callback order, +sanitation, face names, and replacement semantics. The frontend owns +separators (using the adjacent run's face), grapheme shaping, clipping, +and the protected diagnostic/cursor/scroll suffix; none of those pixel +decisions return to the daemon. ## Frontend → instance: `Viewport` diff --git a/docs/statusline-segments-framing.md b/docs/statusline-segments-framing.md new file mode 100644 index 0000000..612f89a --- /dev/null +++ b/docs/statusline-segments-framing.md @@ -0,0 +1,1009 @@ +# Statusline segments - framing (Arc 4 stage 3) + +**Revision 3 - 2026-07-21. Implemented on branch +`statusline-segments` against current `main` `bb17ec9` (#123 atop #124, +protocol v17). It advances the wire to v18, satisfies Acceptance 1-27, +and is fully gated; awaiting review, not merged.** + +Revision 3: closes review findings on authoritative-empty baseline retention +and the TUI's protected-suffix clipping boundary. + +The implementation review corrected the record for the GPU's +built-in-only narrow-band case: stage 3 deliberately changes the legacy +clipping edge and now pins that behavior with a headless regression test. + +Revision 2: closes review findings on invalidation, terminal-control-safe +grapheme painting, separator ownership, detached-frontend latches, and the +unknown-LSP label. Revision 1 was the initial post-#124 architecture scout. + +Arc 4 names three deliverables: named UI faces, a live GPU font +preference, and a Lua statusline-segment API +(`docs/roadmap-2026-07.md:83-90`). Stages 1 and 2 landed as #120 and +#124. This framing covers **stage 3 only**. It adds composable Lua +providers to the per-window modeline, carries their text plus face +names to semantic frontends at protocol v18, and uses the existing LSP +status tracker as the first built-in provider. Completing this stage +completes Arc 4. + +## Implementation record (2026-07-21) + +The approved Q#SL1-Q#SL11 design is implemented without changing the +framed ownership boundary: + +- `pmacs.statusline` owns a shared editor-global registry with strict + registration, lifecycle/introspection, monotonic layout/face-set + epochs, borrow-released three-phase evaluation, per-context failure + latches, deterministic ordering, and bounded one-line results. +- TUI composition preserves the legacy modeline when providers are + absent, owns separators by adjacent segment face, shapes terminal-safe + grapheme runs, and protects the right diagnostic/cursor/scroll suffix. +- Protocol v18 appends complete `StatuslineSegments` replacements. The + semantic producer distinguishes authoritative empty from no message, + versions provider execution before callbacks, expands dynamic + `ThemeFacts`, and resets buffer baselines symmetrically with + `BufferSnapshot`. +- The GPU consumes v18 atomically, resolves exact dynamic faces, clips + provider runs without wrapping or displacing the protected suffix, + deliberately right-pins over-wide built-in-only readouts, and + preserves its prior valid state on malformed input. +- `builtin/runtime/lsp.lua` registers the first pure right-side provider + from its private attachment map; the Rust tracker exposes bounded + `init`/`ready`/`degraded`/`crashed`/`stopped`/unknown labels. + +The final gate run was sequential and clean: `cargo fmt --check`; +workspace/all-target Clippy with `-D warnings`; 1,619 default and 1,793 +CRDT library tests; 7 default and 8 CRDT stage-3 acceptance tests; 114 +M4 acceptance tests (3 ignored, `basedpyright` filtered); 109 required +GPU tests; and the one-invocation workspace sweep (2,718 passed across +78 suites, 19 ignored, `basedpyright` filtered). `git diff --check` was +clean. No flaky rerun was needed. + +## Ground truth (as of `main` at `bb17ec9`, protocol v17) + +### There are two different bottom surfaces in the TUI + +- `EditorCore.status: String` is one global, one-line transient message + (`src/editor_core.rs:234-235`). Lua writes it through + `pmacs.editor.set_status` (`src/lua_bindings/mod.rs:11414-11422`). + `dispatch_key` clears it at entry (`src/editor.rs:677-685`), and the + optimistic CRDT self-insert path clears it too + (`src/daemon.rs:2159-2165`). +- Every TUI window reserves its own final row for a **modeline**. + `paint_frame` renders all windows in the active frontend's layout, + then calls `paint_mode_line` with buffer name, modified state, + active-window marker, diagnostics, cursor L:C, and scroll state + (`src/editor.rs:2102-2240`). The current formatter has a left string + (`+/-`, modified marker, name) and a right string (diagnostics, L:C, + scroll); the right side is right-aligned and dropped wholesale if it + is wider than the window (`:2556-2617`). +- The terminal's last physical row is a separate **global echo row**. + `build_status_line` contains only `core.status`, the last captured Lua + error, and an in-flight key prefix (`src/editor.rs:2791-2827`). + Isearch or the minibuffer paints over that row afterward + (`:2244-2265`). Per-window buffer facts deliberately do not live + there. +- `ui.modeline` owns the per-window row within its stage-1 + `{fg,bg,reverse}` mask. `ui.statusline` owns the global echo row's + foreground only. Search/minibuffer text uses `ui.minibuffer` + (`docs/theme-faces-framing.md` Q#TH3/Q#TH5). The two face names are + not synonyms. +- Modeline width currently counts `char`s, not terminal display + columns (`editor.rs:2594-2616`). A custom CJK or combining segment + would therefore overlap its neighbor unless this stage moves the + whole modeline through Unicode display-width discipline. +- The cell protocol already has `Glyph::Cluster` for a UTF-8 grapheme + plus `Glyph::Continuation` for its trailing columns, and the terminal + emitter writes clusters verbatim (`pmacs-protocol/src/cell.rs:65-77`; + `src/frontend.rs:580-591`). `TextView` still skips combining marks, + but that older limitation need not be copied into this new painter. + `unicode-segmentation` is currently only transitive through + cosmic-text; using it in the core requires one direct manifest entry. + +### The GPU compresses those surfaces into one physical band + +- `StatusFacts` (protocol v8, widened at v15) carries daemon-owned + buffer name, modified flag, error/warning counts, and the transient + `core.status` message. Cursor and scroll deliberately stay + frontend-derived so they follow the optimistic caret + (`pmacs-protocol/src/message.rs:764-792`; + `docs/pmacs-gpu-status-band-framing.md` Q#S1). +- `SemanticRenderState::last_status` is a per-buffer peer-emission + baseline. `status_facts_msg` frame-polls cheap Rust state and emits + only on payload change (`src/semantic_render.rs:176-180`, + `:909-976`). `on_buffer_snapshot_sent` removes that baseline because + the frontend snapshot clears its buffer-scoped status mirror + (`:412-450`). +- GPU composition has one left glyphon buffer and one right glyphon + buffer. The left side's priority is minibuffer, isearch, transient + message, then buffer name/modified (`pmacs-gpu/src/main.rs:4033-4087`). + The right side is diagnostics followed by optimistic L:C and scroll + (`:3971-4030`). Both use string-equality shaping caches + (`:4089-4137`); `ThemeFacts` clears those caches because colors can + change while strings do not (`:3035-3047`). +- The right buffer is measured and positioned flush right; the left + buffer's clip ends before it (`main.rs:5318-5408`). Search, + minibuffer, and transient messages replace only the left content. + Diagnostics/cursor/scroll remain visible on the right. +- Unlike the three popup buffers, neither status glyphon buffer is + currently set to `Wrap::None` (`main.rs:2171-2201`). Long custom text + would otherwise wrap before its measured origin can enforce the + single-band clipping policy. In pinned glyphon 0.11, + `TextArea.left` is an independent `f32` origin and `TextBounds` + performs clipping, so a negative origin is supported without + reshaping away the protected right suffix. +- `BufferSnapshot` clears spans, decorations, adornments, summary, + completion, search, menu, and `status_facts`; it deliberately keeps + global minibuffer, theme, and font state (`main.rs:2736-2818`). + A new buffer's first closed prompt state may be suppressed, so every + new buffer-scoped status mirror must join this symmetric reset + contract rather than wait for a later close message. + +### The old `ModeLine` wire variant is not this feature's carrier + +- `InstanceMessage::ModeLine(Vec)` has existed since the first + protocol and remains unused (`pmacs-protocol/src/message.rs:506-510`; + the only consumers are silent-drop/debug-name arms). It contains + daemon-painted grid cells, not structured text and face names. +- The status-band framing already rejected it: preformatted cells bake + TUI layout into a frontend that owns font shaping and would make a + daemon-formatted cursor visibly lag optimistic typing + (`docs/pmacs-gpu-status-band-framing.md` Q#S1). +- Changing that existing variant's shape would be a wire break under an + already-shipped discriminant. Reusing it unchanged would contradict + both the frontend-local-rendering boundary and this arc's requirement + that segments carry face names rather than raw colors. + +### Lua has provider and error-isolation precedents, but no statusline registry + +- `pmacs.completion.register { name, priority?, fn }` returns a stable + userdata handle and supports unregister, priority, enable, and + introspection (`src/lua_bindings/mod.rs:10624-10712`). The completion + registry establishes the repository pattern for composable + Lua-defined providers. +- Hooks snapshot callbacks before invocation so a callback can re-enter + its registry without a `RefCell` double borrow (`src/hook.rs:250-259`). + Hook callback errors are isolated and appended to `*errors*` + (`src/lua.rs:278-306`). +- `paint_frame` takes a mutable `EditorCore` borrow before walking + windows and holds it through both bottom surfaces + (`src/editor.rs:2120-2250`). Calling arbitrary Lua inside + `paint_mode_line` would let an ordinary provider call + `pmacs.window.*` or `pmacs.buffer.*` and immediately double-borrow + the core. Provider evaluation must therefore happen before that + paint borrow, against owned context snapshots. +- The daemon stamps `core.active_frontend` before every frontend's + projection (`src/daemon.rs:958-960`) and at session establishment + (`:1426-1428`). `pmacs.frontend.id()` consequently has the correct + per-session value during a pre-render provider fan-out. +- `EditorCore` already owns distinct layouts/windows per + `FrontendId`; `active_window_for(fid)` has no cross-frontend fallback + (`src/editor_core.rs:512-526`). A grid frontend may have several + visible windows, while the current semantic GPU has one active + buffer/view. Provider output must be evaluated and cached per + frontend/window context, never as one global string. + +### A real first consumer is already waiting + +- `LspStatusTracker` exists specifically as the stable higher-level + state a modeline can read (`src/lsp_status.rs:30-85`). Its tracker + labels are the bounded set `init`, `ready`, `idx`, `degraded`, + `crashed`, and `stopped`; `pmacs.lsp.modeline_label` additionally + returns `"?"` for a forgotten/unknown server id (`src/lsp.rs:1190`). +- Lua already exposes `pmacs.lsp.modeline_label(server)` and a richer + `status_summary` intended for one call per render frame + (`src/lua_bindings/mod.rs:8700-8805`). +- `builtin/runtime/lsp.lua` owns the authoritative + buffer-handle-to-attachment map. Its public `active_attachment` + deliberately reads only the active window (`:721-731`), but a + statusline provider in that same Lua chunk can safely index the + private map by a passed `ctx.buffer`, including passive TUI windows. +- Despite comments saying LSP data feeds a modeline, no renderer + currently consumes it. Stage 3 can prove the API on a shipped, + useful segment instead of landing an unused extension point. + +### ThemeFacts currently cannot represent arbitrary segment faces + +- `Theme::face(name)` owns daemon-side dotted-prefix inheritance for + `ui`/`ui.*` names and returns `None` when unset + (`src/highlight.rs:207-226`). +- The namespace predicate itself currently lives only in the main + crate as `highlight::is_face_name` (`src/highlight.rs:92-95`). + `pmacs-gpu` cannot import that crate without reversing the dependency + graph, so merely calling two copied expressions "shared" would leave + registration and the untrusted wire boundary free to drift. +- The `ThemeFacts` producer resolves only the fixed twelve stage-1 face + names in `UI_FACES` (`src/semantic_render.rs:281-299`, + `:1137-1177`). Frontends perform exact-name lookup; they never walk + parent names. +- Therefore a segment naming `ui.modeline.lsp` cannot inherit a + configured `ui.modeline` on the GPU unless the producer learns that + exact referenced name and ships its resolved style. Sending raw + theme entries and reimplementing the walk frontend-side would + contradict Q#TH7. + +### Protocol placement + +- `PROTOCOL_VERSION == 17`; supported versions are `6..=17` + (`pmacs-protocol/src/message.rs:1414`, `:1472-1480`). +- `FontFacts` is the final variant. Postcard enum discriminants are + ordinal; stage 2 pinned the byte encoding of the final pre-v17 + `ThemeFacts` variant. Stage 3 must append after `FontFacts` and pin + `FontFacts` bytes before changing the enum. + +## Decisions + +### Q#SL1 - Scope: additive per-window modeline segments; Arc 4 ends here + +Stage 3 extends the **per-window modeline/status band**, not the global +echo area: + +- TUI: custom left/right segments render on each visible window's + modeline. +- GPU: the same custom segments render in the existing status band, + scoped to its current buffer. +- The TUI echo row remains owned by `core.status`, Lua errors, pending + keys, isearch, and minibuffer. `pmacs.editor.set_status` is unchanged. +- GPU minibuffer/isearch/transient-message precedence remains + unchanged. The physical single-band compromise is explicit in Q#SL5. +- Existing buffer identity, modified state, diagnostics, cursor L:C, + and scroll facts remain built in. This API is additive; replacing, + removing, or arbitrarily reordering those built-ins is Deferred. +- Cursor and scroll remain frontend-derived. A Lua provider receives no + cursor/scroll value in its context; sending the daemon's cursor as a + custom segment would regress optimistic freshness by design. + +No popup, click action, second row, or new layout surface is in scope. +Protocol v17 -> v18 is reserved for one additive segment-facts variant. +When this stage lands, Arc 4 is complete. + +### Q#SL2 - Lua surface: composable provider registry + +The new module is `pmacs.statusline`: + +```lua +local handle = pmacs.statusline.register { + name = "my-project", + side = "left", -- required: "left" or "right" + priority = 20, -- optional signed 32-bit integer; default 0 + face = "ui.modeline.project",-- optional; default "ui.modeline" + fn = function(ctx) + if not ctx.buffer then return nil end + return "project" + end, +} + +pmacs.statusline.set_priority(handle, 50) -- true iff handle is live +pmacs.statusline.set_enabled(handle, false) +pmacs.statusline.unregister(handle) +local providers = pmacs.statusline.providers() +``` + +Contract: + +- A new `SharedStatuslineRegistry` is installed from `EditorState::new` + before `builtin/runtime/lsp.lua`, stored on `EditorState`, and passed + by reference to both grid and semantic renderers. User config still + runs after all builtins, so it can discover and tune the built-in LSP + provider. Bare test states construct an empty registry rather than an + optional/absent surface. +- `register` returns a stable `StatuslineProviderId` userdata. Names are + non-empty display/debug labels, not unique keys; handles own + lifecycle, matching completion providers and package unload + discipline. Registrations start enabled; ids are monotonic and are + never reused, so registration-id tie breaks remain stable. The + binding captures `caller_source(lua, 2)` at registration for later + error attribution. +- The registration table is strict plain data. Raw keys are exactly + `name`, `side`, `priority`, `face`, and `fn`; an unknown key is + rejected with its name. Raw reads/traversal do not invoke + `__index`/`__pairs`. `name`, `side`, `face`, integer range, and + function type are completely validated before mutating the registry. + Priority accepts a finite, mathematically integral Lua number in the + signed-32-bit range on both LuaJIT and Lua 5.4; strings/fractional + values do not coerce. +- The namespace tests move to dependency-neutral protocol helpers: + `pmacs_protocol::is_ui_face_name` retains the exact stage-1 + `name == "ui" || name.starts_with("ui.")` reservation, while + `is_modeline_face_name` accepts only `ui.modeline` or + `ui.modeline.*`. The core's `highlight::is_face_name` delegates to + the former; statusline registration, ThemeFacts expansion, and GPU + wire validation delegate to the latter. A modeline segment cannot + borrow another surface family's special mask/Default policy. + Statusline registration additionally requires valid UTF-8, rejects + control characters, and bounds `name` and `face` to + `MAX_STATUSLINE_PROVIDER_NAME_BYTES` / `MAX_STATUSLINE_FACE_BYTES` + (256 each). +- `face` is static for the registration. Dynamic face changes use two + providers or unregister/register; this keeps the authoritative face + inventory knowable without executing user code. +- The callback returns a valid UTF-8 string or `nil`. `nil` and the + empty string omit the segment and contribute no separator. Invalid + UTF-8 or any other return type is an isolated provider error. +- At most `MAX_STATUSLINE_PROVIDERS` (64) registrations may be live. + Disabled registrations still count; unregistering releases the slot. + This makes the producer's wire-size bound structural rather than a + lossy "drop some providers after evaluation" policy. +- Returned text is flattened with the existing one-line policy: stop at + the first `\n`, replace other control characters with spaces. A + post-sanitization value above `MAX_STATUSLINE_SEGMENT_BYTES` (1024) + is a provider error rather than an unbounded wire/shaping input. +- `providers()` returns fresh plain metadata tables in registration + order: handle, name, side, priority, face, enabled. It never exposes + the stored function. +- `set_priority` and `set_enabled` return `false` for a stale handle; + an actual change advances registry state. Mutator arguments are also + strict raw types (`set_enabled` accepts only a boolean, never Lua + truthiness). `unregister` is idempotent and returns whether a live + provider was removed. +- The module/registry installs before `builtin/runtime/lsp.lua` and + before user config. Registration and all mutators are live + mid-session, not init-gated. + +The registry carries two monotonic counters: + +- `layout_epoch`: register/unregister, actual priority changes, and + enable changes. It guards evaluation snapshots and orders. +- `face_set_epoch`: register/unregister and enable changes that alter + the enabled referenced-face set. It keys `ThemeFacts` expansion + (Q#SL6). Priority-only changes do not make every semantic session + re-resolve theme faces. + +Both advance from their prior values and never reset. + +### Q#SL3 - Callback context and evaluation lifecycle + +Each enabled provider is called once per rendered window context: + +```lua +ctx = { + frontend = 7, -- integer FrontendId + window = 42, -- integer WindowId + buffer = buffer_id, -- normal pmacs buffer-handle userdata + active = true, -- focused window within that frontend +} +``` + +There is deliberately no terminal width, pixel width, cursor, scroll, +or frontend-kind field. Layout stays frontend-local; providers produce +semantic text, not presentation guesses. A provider that supports +passive split windows must read `ctx.buffer`, not +`pmacs.window.buffer()` (which names the focused window). + +Evaluation is a three-phase, borrow-released transaction: + +1. Borrow the core only long enough to capture the target frontend's + visible `(window, buffer, active)` contexts. For the semantic path, + capture only `active_window_for(frontend_id)` and require its buffer + to match the declared viewport; during a snapshot -> new-viewport + transition, emit nothing for the stale viewport. +2. Snapshot enabled provider definitions plus `layout_epoch`, release + every core/registry borrow, then invoke Lua in the deterministic + order from Q#SL4. Every call gets a fresh context table. +3. Re-read `layout_epoch` and the core contexts. Publish the owned + results only if the registry epoch is unchanged and every + `(frontend, window)` still exists on the same buffer with the same + active flag. A callback that changes layout, switches/kills a buffer, + or registers/unregisters/disables a provider makes this evaluation + **invalid**. Invalid is not a silent dropped fan-out: for the + declared matching v18 buffer, the producer emits an authoritative + replacement `StatuslineSegments { left: [], right: [] }`, records + that empty payload as the new emission baseline only after queuing + the replacement, and discards every evaluated result. The next frame + therefore stays silent if the surviving truth is also empty, or + emits the newly evaluated non-empty truth as a change from empty. If + a callback changed the initially matching window away from the + declared buffer, the empty replacement clears that prior buffer's + mirror before the next frame evaluates the new truth. A snapshot -> + new-viewport transition that was already stale at phase 1 instead + follows that phase's no-message rule: `BufferSnapshot` has already + cleared the frontend mirror, and `on_buffer_snapshot_sent` owns the + corresponding baseline removal. Thus no callback mutation can leave + a prior non-empty GPU payload resident indefinitely, and no invalid + evaluation creates a redundant second empty send. + +The TUI calls the evaluator at the start of `paint_frame`, before the +long-lived mutable core borrow. `SemanticRenderState::render_frame` +calls it before producing `StatuslineSegments`, but only for a peer +that negotiated v18. A v17 semantic peer pays no Lua callback cost for +an unsupported surface. The daemon already stamps `active_frontend` +before both paths, so `pmacs.frontend.id()` agrees with `ctx.frontend`. + +Provider failures are independent: + +- One error or invalid return omits only that provider. Later providers + still run and all built-in facts still render. +- The first failure in a consecutive failure run is appended to + `*errors*` with provider name and registration source. Repeating the + same failing callback every frame does not flood the buffer. Latches + are keyed by the full `(provider_id, frontend_id, window_id, + buffer_id, active)` context: success in one split must not re-arm a + provider that keeps failing in another, and switching a window to a + different buffer or focus role starts a truthful new failure run. + A successful string-or-`nil` result clears only that context's latch, + so a later failure there is reportable again. Unregister and stale + context cleanup discard the corresponding latches; disabling a + provider clears all of its latches so re-enable begins a new run. + Frontend detach also discards every latch keyed by that `FrontendId` + (with a live-context sweep as defense in depth), so a detached session + cannot retain failure suppression into a later reconnect. +- Evaluation snapshots definitions before calls; a provider may + unregister itself without a `RefCell` panic. The epoch guard drops the + old fan-out's result and takes the authoritative-empty invalidation + path above. +- Providers are documented as pure, fast render functions. The binding + cannot prevent a callback from invoking editor mutators, but the + context/epoch guard prevents wrong-window publication; recurring + mutation loops are user-code bugs, not an implicit scheduling API. + +No content epoch is assumed. LSP/process/async state can change without +touching the registry, so enabled callbacks are polled each render. +Owned output is payload-compared before wire emission; an empty registry +or no enabled providers is an O(1) fast path. + +### Q#SL4 - Composition, order, separators, and narrow-window policy + +Current built-in positions remain anchored: + +- **Left:** the frontend's current active/modified/buffer-identity group, + with its existing edge padding, then custom left segments. +- **Right:** custom right segments, then the frontend's current + diagnostic/cursor/scroll group with its existing internal and edge + spacing. + +The compositor inserts exactly one ASCII space between adjacent custom +segments and at a custom/built-in boundary. Provider text does not need +to carry padding. No separator is emitted for `nil`/empty results. +Every compositor-inserted separator is a base `ui.modeline` run: it +never inherits an adjacent custom segment face. Legacy built-in internal +spacing retains its current base modeline styling too. This rule is +identical in TUI cells and GPU rich text, so a face colors only the +provider's visible text, not the gaps around it. +Each legacy built-in group stays atomic and byte-for-byte unchanged +inside: in particular, stage 3 does not normalize the GPU's existing +two-space diagnostic/readout separators to the TUI's one-space +formatting. + +Priority means **survival priority when horizontal space is tight**: + +- Left custom providers are ordered by `(priority descending, + registration id ascending)`. Higher-priority items sit closest to the + leading-edge buffer identity. Overflow clips the low-priority tail. +- Right custom providers are displayed by `(priority ascending, + registration id ascending)`, placing higher-priority items closest to + the protected diagnostic/cursor/scroll suffix. The complete right run + is right-aligned; overflow clips its low-priority left edge. +- The protected built-in suffix is never discarded merely because a + custom provider is long. If the built-in suffix itself cannot fit, + the TUI retains its legacy wholesale drop. The GPU deliberately + changes its legacy narrow-band policy: before stage 3 it pinned the + built-in group's left edge and clipped the right tail; stage 3 pins + the right edge and clips the left so the readout tail survives. + Custom-prefix clipping preserves the complete built-in suffix only + when that suffix fits by itself. +- The left group gets the space before the right group's measured + origin and clips at the collision boundary, without the legacy GPU's + extra 10-pixel gap. It never overwrites the right group. This anchors + buffer identity at the leading edge but does not guarantee its + survival: an over-wide right group may consume all available left + space. + +This asymmetric visual ordering is intentional: priority determines +what survives, not a generic ascending sort that would protect opposite +ends on the two sides. Registration id makes ties deterministic across +TUI painting, payload comparison, and wire encoding. + +### Q#SL5 - Echo/minibuffer precedence on the single GPU band + +The TUI always keeps modelines visible while its separate global row +shows a message, search, or minibuffer. The GPU has one physical band, +so exact topology parity is impossible without adding a second GPU +surface (Deferred). Stage 3 follows the existing content priority: + +- Ordinary buffer-name state: buffer identity followed by custom left + segments. +- Minibuffer, isearch, or transient message state: that content owns + the whole left group; custom left segments are suppressed. +- Custom right segments remain visible with the existing + diagnostic/cursor/scroll right group, just as that group remains + visible during minibuffer/search/message state today. + +This makes custom segments modeline content, never echo content. +`ui.statusline` continues to color transient messages only. + +### Q#SL6 - Segment faces and dynamic ThemeFacts inventory + +Every segment carries a face **name**, never raw color. The registered +default is `ui.modeline`; a typical package uses a child such as +`ui.modeline.lsp`. + +Segment faces have a stage-3 component mask of **visual `{fg}` only** +on both frontends: + +- The modeline/status-band background remains wholly owned by + `ui.modeline`; a text segment cannot create a per-run background on + one frontend only. +- The default face name `ui.modeline` and an unresolved custom face keep + the base modeline's EFFECTIVE text color after its own reverse + mapping. +- A resolved custom face applies only its logical `fg` as the + POST-modeline visible glyph color when that component is concrete. + `Default` means "use the effective base modeline text color": an + exact all-default child still blocks a colored intermediate parent, + but returns the run to the base rather than trying to express a + terminal-default foreground through a reversed background channel. + The visible background remains the base modeline surface. + Out-of-mask bg/bold/italic/underline/reverse fields are ignored by + both frontends. +- The TUI's built-in modeline is normally `reverse = true`. To apply a + visible glyph color without changing that surface, the cell painter + writes the override into the base style's logical `bg` when reverse + is set, and into logical `fg` otherwise. After the terminal performs + reverse, the requested color is the glyph foreground in both cases. + The GPU writes the same requested color into the glyphon run. +- A `ui.modeline.*` custom child uses **base-relative inheritance**: + walk exact child/intermediate entries but stop before + `ui.modeline`; reaching the base means "no override", so the segment + inherits the modeline's already-mapped effective text color. This + avoids taking `ui.modeline`'s pre-reverse logical `fg` and applying it + as a post-reverse glyph color. One shared + `Theme::modeline_segment_face` helper owns this rule for TUI + resolution and ThemeFacts production. A concrete custom foreground + returns a mask-normalized `Style { fg, ..Default::default() }`; a + found Default foreground stops inheritance and returns `None` (base). + Out-of-mask components never enter the dynamic wire table. +- GPU performs exact lookup in `ThemeFacts`; absence means base + modeline text. Existing `Indexed` palette divergence remains the + stage-1 accepted behavior. + +For semantic peers at v18, the `ThemeFacts` inventory becomes: + +```text +fixed stage-1 UI_FACES +UNION +distinct face names of enabled statusline providers +``` + +The union is sorted/deduplicated. Custom names resolve through +`Theme::modeline_segment_face`: exact/intermediate concrete foreground +overrides are shipped, while a name that reaches the base or finds a +Default foreground is omitted and therefore uses the frontend's +effective modeline text. Thus an unset `ui.modeline.lsp` correctly +follows a configured, possibly reversed `ui.modeline` without shipping +a pre-reverse component under a post-reverse mask. Frontend lookup +remains exact; the Q#TH7 ownership boundary does not move. + +`theme_facts_msg` keys its computation on +`(theme.face_epoch, statusline.face_set_epoch)` for a v18 peer. Both +cache records advance on computation; payload equality can suppress a +send. Removing/disabling the last provider for a custom face removes +that entry from the next authoritative table. For v16/v17 peers the +inventory stays the fixed stage-1 list: they cannot render segments and +pay no irrelevant face traffic. + +If a face-table change and segment payload occur in one frame, +`ThemeFacts` is ordered before `StatuslineSegments`. A theme-only +recolor sends `ThemeFacts` but not unchanged segment text; the GPU face +arm invalidates both status shaping caches, so existing runs reshape +under the new color. The invalid-evaluation authoritative-empty path +uses this same ordering: a provider removal may remove its dynamic face +from `ThemeFacts`, but its prior non-empty segment payload is replaced +by empty vectors in that frame rather than being retained beside the +reduced face inventory. + +### Q#SL7 - Wire: `StatuslineSegments`, protocol v18, appended final + +```rust +/// One daemon-produced custom modeline segment. Text has already been +/// sanitized to one line; `face` is ui.modeline or a child name. A +/// custom override, when set, is resolved in the authoritative +/// ThemeFacts table; absence means the base modeline text color. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct StatuslineSegment { + pub text: String, + pub face: String, +} + +/// Arc 4 stage 3 (protocol v18): custom Lua modeline output for the +/// semantic frontend's current buffer. Complete replacement each +/// send; empty vectors authoritatively mean no custom segments. +StatuslineSegments { + buffer_id: BufferId, + left: Vec, + right: Vec, +}, +``` + +- Append after `FontFacts`, the final v17 variant. Before appending, + add a byte-level encoding pin of representative `FontFacts` values; + the new variant's own round-trip cannot detect an accidental ordinal + shift of old channels. +- `PROTOCOL_VERSION` becomes 18; supported versions become `6..=18`; + the ladder accepts 18 and rejects 19. Add populated and empty + postcard round-trips. +- Daemon write-loop and producer both gate at negotiated `>=18`. + A v17 GPU keeps today's built-in band. The grid TUI silently drops + the semantic-only variant if one is delivered unexpectedly. +- The payload contains custom provider output only. Existing + `StatusFacts` remains unchanged at v15; widening it would move its + whole gate to v18 and unnecessarily darken buffer/diagnostic facts + for v15-v17 peers. +- `docs/semantic-frontend-protocol.md` records the v18 schema, + authoritative-empty rule, ordering after `ThemeFacts`, snapshot + reset, and the division between custom daemon text and + frontend-derived cursor/scroll. + +Wire values are untrusted at the GPU boundary. Before replacing current +state, the GPU validates the whole message atomically. The provider, +segment-text, face, and total-text limits live as public constants in +`pmacs-protocol`; registration/production and consumption do not copy +numeric policy: + +- no more than 64 segments total across both sides; +- total text bytes no more than 64 KiB; +- each text is non-empty, at most 1024 bytes, and contains no control + character; +- each face is at most 256 bytes, contains no control character, and + satisfies `pmacs_protocol::is_modeline_face_name`. + +An invalid message is logged and ignored wholesale; the prior valid +state remains. These bounds protect shaping/layout even if a malformed +peer bypasses the trusted Lua producer. + +### Q#SL8 - Producer, emission baselines, and snapshot symmetry + +`SemanticRenderState` gains: + +- `peer_knows_statusline_segments: bool`; +- `last_statusline: HashMap, + Vec)>`. + +After viewport declaration and only when the declared buffer matches +the frontend's active daemon window, the producer evaluates the active +context and compares the complete ordered payload: + +- First sight of every buffer emits an authoritative message, including + `(left=[], right=[])`. +- Changed output emits one complete replacement. +- Byte-identical output is silent even though callbacks were evaluated. +- Back-to-back state changes before one frame legitimately coalesce into + the latest payload. +- `on_buffer_snapshot_sent(buffer_id)` removes that buffer's baseline. + An unchanged A -> B -> A revisit must re-send A's segment payload. + +The GPU `BufferSnapshot` arm clears its custom left/right segment +mirror alongside `status_facts`, search, and menu. `ThemeFacts` and the +provider registry remain global and survive. This is the #120 +snapshot/baseline contract applied symmetrically, not a new special +case. + +### Q#SL9 - TUI rendering: styled runs and display-column correctness + +`paint_mode_line` stops flattening each side to an unstyled `String`. +It receives logical runs `(text, effective Style)` and uses one shared +single-row painter: + +- Before grapheme segmentation, **every** logical run passes through a + shared terminal-control sanitizer: provider text, buffer names, mode + markers, diagnostics/readouts, and compositor separators alike have + all control scalars (including CR, LF, and ESC) replaced with spaces. + Provider-return sanitation remains an earlier validation boundary; + this final run-level pass is defense in depth for core-owned text. + Consequently `Glyph::Cluster`, whose frontend emitter writes bytes + verbatim, can never carry a terminal control sequence. +- Runs are split with `UnicodeSegmentation::graphemes`; width and + clipping use `UnicodeWidthStr` on each complete grapheme. This stage + adds `unicode-segmentation` as a direct dependency rather than + relying on cosmic-text's transitive copy. +- A one-scalar grapheme writes `Glyph::Char`; a multi-scalar printable + grapheme writes `Glyph::Cluster`. Every extra display column writes a + `Glyph::Continuation`; clipping never emits half a wide grapheme. + A standalone zero-column grapheme is skipped, while a combining + sequence such as `e` + U+0301 remains one visible cluster. +- Left/right collision uses display columns, not scalar count or UTF-8 + bytes. +- The row is still filled once with the `ui.modeline` base style. + Built-in runs keep that style. A set segment face replaces only the + run's visible foreground per Q#SL6, writing logical `bg` rather than + `fg` when the base row is reversed. +- Every separator inserted by Q#SL4 is likewise painted with this base + style, regardless of the faces on either side. +- When the protected built-in right suffix fits by itself, clipping the + combined right group removes only the low-priority custom prefix and + preserves that suffix in full. If the built-in suffix itself does not + fit, the TUI retains today's wholesale drop instead of introducing a + new partial-suffix policy. Left clipping keeps the prefix. No run can + write outside its window rect or into another split's modeline. + +With no visible provider output, the resulting cells are byte-for-byte +the current modeline for ordinary ASCII buffers. + +### Q#SL10 - GPU application: rich runs, cache invalidation, clipping + +GPU state stores the latest validated custom segment vectors plus their +`buffer_id`. Composition filters them against `current_buffer_id`, the +same belt as `StatusFacts`. + +- Right custom segments are inserted before diagnostic/cursor/scroll + spans. Each segment becomes a rich-text run. The color resolver + special-cases `ui.modeline` and an absent child to the already-mapped + base modeline text color; a present child maps only its concrete + `fg`, with defensive Default handling also selecting the base. It + never re-applies the base face's pre-reverse logical foreground. +- Ordinary left composition becomes rich text: buffer-name/modified + base run followed by custom left runs; each Q#SL4 separator is its + own base-color rich run. Modal/message states produce their existing + single content run and no custom left runs. Right-side custom/built-in + separators are likewise base-color runs, never extensions of an + adjacent provider face. +- The two shaping caches become + `Option>`, seeded/invalidation-set to + `None`, and retain the complete ordered rich-run vectors after shape. + Concatenation is not a sufficient key once `"buffer" + custom` can + equal a transient/minibuffer string byte-for-byte while requiring + different attributes; an empty vector is legitimate content, not an + invalidation sentinel. Cache state advances only after the matching + rich text has been installed. +- Applying a changed `StatuslineSegments` payload clears both status + shaping caches before redraw. This is required even when concatenated + text is unchanged but a face name changed. +- Both status glyphon buffers use `Wrap::None`, set at construction and + retained across the FontFacts metric transaction. They remain + single-line surfaces even when a custom segment is wider than the + viewport. +- Right placement uses the full shaped width without clamping its + origin to `TEXT_LEFT`: the run's right edge stays at the right pad, + while a negative/left-of-surface origin clips low-priority custom + prefixes and preserves the built-in tail. This intentionally changes + the legacy built-in-only narrow case, which anchored the readout at + `TEXT_LEFT` and clipped its right tail. +- The left TextArea clips at the right group's actual origin rather + than retaining the legacy extra `STATUS_TEXT_PAD` gap. The right + group therefore owns collision priority and may fully obscure the + left buffer identity in an extremely narrow band. Existing geometry + bounds still keep all glyphs inside the band. +- `ThemeFacts` continues to invalidate both caches. FontFacts already + re-metrics/re-shapes both status buffers; the new rich runs ride that + path without a new font transaction. + +The message does not request a viewport re-declaration: status text +changes no code geometry or visible-line count. + +### Q#SL11 - Built-in LSP segment proves the extension point + +After `pmacs.statusline` is installed, `builtin/runtime/lsp.lua` +registers one right provider: + +```lua +pmacs.statusline.register { + name = "lsp", + side = "right", + priority = 0, + face = "ui.modeline.lsp", + fn = function(ctx) + local rec = attachments[tostring(ctx.buffer)] + if not rec then return nil end + return "LSP:" .. pmacs.lsp.modeline_label(rec.server) + end, +} +``` + +It is pure: it never triggers attachment, flushes didChange, or mutates +the server. It indexes the private attachment map by `ctx.buffer`, so +passive split windows show their own buffer's state. No attachment means +`nil`, preserving today's modeline outside LSP-backed buffers. + +The face name is intentionally a new child. Unset, it inherits +`ui.modeline`/the built-in segment color. A user can theme LSP state +without changing the whole band: + +```lua +pmacs.theme.merge { + ["ui.modeline.lsp"] = { fg = 6 }, +} +``` + +The provider handle appears in `pmacs.statusline.providers()`, so user +config can disable or reprioritize it without a special LSP option. + +## Bets + +- Additive providers are sufficient for the first extensibility stage: + they deliver real package/user value without turning optimistic + cursor/scroll facts into stale daemon text or destabilizing the + existing default layout. +- Static registration faces plus the dynamic ThemeFacts inventory keep + inheritance daemon-owned and make face availability independent of + callback output. No frontend walk or raw color enters the API. +- Per-render Lua polling is the honest freshness mechanism. Generic + callbacks can depend on LSP/process/plugin state with no shared epoch; + payload comparison keeps the wire quiet, and an empty registry takes + the O(1) fast path. The existing `status_summary` API was already + shaped for one call per render frame. +- Three-phase evaluation prevents the known core/registry `RefCell` + hazards and fails closed across context-changing callbacks. It does + not pretend arbitrary mutating render code is a supported scheduling + model. +- One authoritative v18 message per buffer plus snapshot-symmetric + reset makes first attach, late join, and unchanged A -> B -> A + revisits correct without an epoch on the wire. +- The priority-at-the-protected-edge rule is deterministic and keeps + today's essential built-ins readable under narrow layouts. +- The first built-in LSP provider validates passive-window context, + live async updates, arbitrary child faces, and cross-frontend wire + rendering in one useful feature. + +## Deferred (named) + +Wholesale replacement/removal/reordering of built-in buffer, +diagnostic, cursor, and scroll components; a frontend-local custom +cursor/scroll token vocabulary; customization of the global echo row; +a second GPU bottom surface that would keep modeline left segments +visible during minibuffer/search/messages exactly like the TUI; +segment click/hover actions and mouse hit maps; multi-row statuslines; +icons/images/resources; per-segment backgrounds, reverse, +bold/italic/underline, and wider chrome masks; `ui.modeline.inactive`; +borrowing face families outside `ui.modeline`; dynamic face names +returned by callbacks; async/yielding providers; +provider-specific separators; timed refresh scheduling below/above the +normal frame cadence; automatic package ownership/unregister (packages +retain handles and use unload hooks today); GPU splits/multi-buffer +status bands (Arc 8 structural work); horizontal scrolling/marquee and +ellipsis policies; repurposing or deleting the legacy +`ModeLine(Vec)` variant. + +## Acceptance + +Primary suite: `tests/statusline_segments_acceptance.rs` for Lua, +TUI, producer, and daemon/wire behavior; protocol pins stay in +`src/protocol.rs`; GPU routes live in the headless +`PMACS_REQUIRE_GPU=1` suite. Dispatch/render tests use real +`RenderState`/semantic frame paths, not direct helper-only formatting. + +1. **Default preservation:** with no visible provider output, scratch + TUI cells and ordinary non-overlapping GPU modeline/status-band + pixels are byte-identical to the pre-stage rendering. The deliberate + GPU narrow-band exception pins an over-wide built-in readout's right + edge and clips its left edge; a built-in-only headless fixture pins + that behavior. The global TUI echo row is unchanged. +2. **Lua strict contract:** valid registration returns a handle and + appears in `providers`; bad/unknown side, empty name, non-integer or + out-of-range priority, non-function `fn`, non-modeline face + (including another valid `ui.*` family), control or over-limit + name/face, provider 65, and unknown key all error with the field/key + named and leave registry epochs and provider list untouched. A + value-providing or raising metatable is never invoked. Protocol, + core, producer, and GPU tests pin the same namespace predicate table + through the shared helpers. +3. **Handle lifecycle:** priority and enable changes affect order/output + and advance only their specified epochs; no-op setters do not; + unregister is true then false; stale-handle setters return false; + fractional/coerced priority and truthy non-boolean enable values + error without mutation. +4. **Callback result contract:** string renders; `nil` and empty string + omit without separators; newline/control output is sanitized; + invalid UTF-8, non-string, and over-limit output omit that provider + and report an error. +5. **Error isolation and latch:** a failing provider between two good + providers does not suppress either neighbor or built-ins; one error + lands in `*errors*`, repeated frames do not append duplicates, a + successful evaluation clears the latch, and a later failure reports + once again. In two splits, success in B does not re-arm a provider + that remains failing in A; closing A or unregistering the provider + releases that context's latch, and disable/re-enable starts a new + failure run. Detaching a frontend releases every latch carrying its + `FrontendId`; reconnecting and failing again reports once rather than + inheriting suppression from the detached session. +6. **Re-entrant registry mutation:** a provider unregistering itself + during evaluation causes no borrow panic and discards the old + fan-out by epoch guard. A semantic producer test first establishes a + non-empty payload (and its custom face) for the matching buffer, then + triggers self-unregister/disable: the invalid evaluation emits one + authoritative empty replacement, the reduced `ThemeFacts` precedes + that replacement, the resulting GPU frame has no prior text, and the + empty replacement becomes the emission baseline. The provider is + absent and a still-empty next frame is wire-silent; a surviving good + provider instead reappears on that next frame as a change from empty. +7. **Context-change guard:** callbacks that switch the window buffer, + close a split, or kill the source buffer cannot publish text under + the old context; the next frame evaluates the surviving truth. +8. **Per-window context:** two TUI splits on different buffers receive + distinct `ctx.window`, `ctx.buffer`, and `ctx.active` values and + render their own text. Focusing the other split flips only `active`; + two frontends cannot consume each other's context/output. +9. **Ordering and separators:** mixed left/right providers with tied and + distinct priorities produce the exact Q#SL4 order, stable id tie + break, and one-space custom boundaries with nil providers absent; + the built-in groups retain their legacy internal spacing. With two + visibly different custom faces, every custom/custom and + custom/built-in separator is pinned to the base `ui.modeline` style + in TUI cells and GPU rich runs/pixels. +10. **TUI placement:** buffer identity remains first on the left; + custom right segments precede diagnostics/L:C/scroll; the global + echo row still shows `pmacs.editor.set_status` independently. +11. **TUI Unicode and clipping bite:** CJK, combining, and ASCII custom + runs beside a right suffix occupy correct display columns with + cluster/continuation cells and no overlap; the combining sequence is + emitted rather than silently dropped. A narrow-split fixture whose + built-in suffix fits by itself clips the low-priority custom edges + while retaining that suffix in full and never writes outside its + rect. A second fixture where the built-in suffix itself does not fit + pins the current TUI wholesale-drop behavior. A buffer name + containing CR, LF, and ESC is sanitized before segmentation: its + resulting `Glyph::Char` / `Glyph::Cluster` cells and captured + terminal bytes contain no raw control scalar or escape sequence. +12. **LSP built-in:** an unattached buffer adds nothing. Attached + buffers show `LSP:init/ready/idx/degraded/crashed/stopped` as the + tracker changes without a buffer edit, and `LSP:?` for a forgotten + server id; a passive split uses its own attachment. + Disabling/reprioritizing the discovered provider handle works. +13. **Version and placement pins:** protocol is 18; ladder accepts + `6..=18` and rejects 19; empty/populated + `StatuslineSegments` round-trip; a byte-level `FontFacts` encoding + pin proves the append shifted no v17 discriminant. +14. **Authoritative first frame and live output:** a v18 session's first + matching-viewport frame carries empty vectors when no provider is + visible, then silence. A callback-state change with no edit/registry + mutation emits exactly one updated payload; unchanged polling is + wire-silent. +15. **Init and late join:** a provider/theme established from + `init.lua` is present in the first attachment's first matching + frame. The same established state is present in a later second + session without a post-attach mutation. +16. **Version gate:** a real-daemon v17 semantic peer receives neither + `StatuslineSegments` nor dynamic provider-only ThemeFacts entries + and does not execute the provider; a v18 peer receives both. Daemon + producer and write-loop gates are independently pinned. +17. **TUI drop arm:** the grid frontend consumes an unexpected + `StatuslineSegments` message without error. +18. **Snapshot round trip:** after A's segment payload is established, + A -> B -> A at unchanged generations re-sends A because the producer + baseline reset; the GPU snapshot clears A's mirror immediately and + restores the exact A pixels only after the authoritative re-send. +19. **Dynamic face inventory:** registering enabled + `ui.modeline.lsp` adds its daemon-resolved exact name to v18 + `ThemeFacts` only when a custom override exists; a configured + `ui.modeline` parent is inherited through base absence (no redundant + child entry), while an intermediate custom parent is shipped under + the exact referenced child name with only `fg` retained; + disabling/removing the last reference removes any custom entry. A + priority-only change does not recompute the face set. +20. **Message ordering and recolor:** when registration and theme + change together, `ThemeFacts` precedes `StatuslineSegments`. + Recoloring a segment face with constant text emits ThemeFacts only + and changes both TUI cells and GPU pixels through cache + invalidation. +21. **Face mask parity:** a segment face carrying + `{fg=F,bg=B,reverse=true,bold=true}` renders exactly like `{fg=F}` + on both frontends, including under the TUI's default reverse row; + an exact empty child blocks a colored intermediate parent and + returns to the effective base text while retaining the base + modeline surface. +22. **GPU normal composition:** ordinary state renders buffer identity + plus differently faced custom left runs, and custom right runs + before colored diagnostics and optimistic cursor/scroll. A changed + face name with identical concatenated text still reshapes. +23. **GPU precedence:** minibuffer, isearch, and transient status each + suppress custom left segments while preserving custom right and the + existing right facts; closing the modal/message restores the custom + left payload without requiring a new segment message. A fixture + makes the ordinary rich composition and transient message + concatenate to identical bytes and proves both transitions reshape + with the correct attributes. +24. **GPU narrow-band clipping:** an over-wide right provider is clipped + at the left edge while diagnostic/L:C/scroll pixels remain at the + right; left content stops before the right origin. Bounds contain + all glyphs at both stage-2 font-size limits. A wrapping-sensitive + fixture proves both status buffers remain one visual row. +25. **GPU wire validation:** direct messages with too many segments, + excess bytes, control text, overlong/invalid face names, or a + face outside `ui.modeline` are rejected atomically with the prior + valid frame byte-identical and no panic; boundary-valid payloads + apply. The predicate cases are the same table exercised by Lua/core + tests, not a copied GPU interpretation. +26. **Unsupported-peer cost:** a semantic v17 render with an enabled + side-effect-counting callback never invokes it. Grid TUI and v18 + semantic renders invoke exactly once per target window per frame. +27. **Docs/handoff:** semantic protocol documents v18 and ownership; + `docs/package-author-guide.md` shows register/unregister lifecycle + and passive `ctx.buffer` use; the roadmap/handoff record Arc 4 + complete once the implementation lands. diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md new file mode 100644 index 0000000..7cd1d8d --- /dev/null +++ b/docs/vterm-framing.md @@ -0,0 +1,1004 @@ +# Vterm — framing (Arc 5 stage 2, three-PR delivery) + +**Revision 5 — 2026-07-21. Status: Stage 1 landed on `main` through PR #126 +at merge `643d1e1`. Stages 2 and 3 are not implemented.** + +Revision 5 establishes the renderer-facing cell invariant before Stage 2: +terminal text discards C0/C1 controls rather than storing host-terminal control +bytes in grapheme cells. SGR mouse release preserves the released button code; +review cleanups remove dead screen paths and stale round-trip state; and the +remaining VT-fidelity and allocation nits are explicit deferrals. Architecture +is unchanged: `C-c` is the terminal editor escape (`C-c C-c` sends interrupt); +main-screen resize reflows while alternate screen clips/pads; exited buffers +remain with an Emacs-style process message; protocol v19 is additive with +complete frames; shared `Style` stays unchanged; and one `BufferId` owns one +shared process/screen whose most recently active frontend controls size. + +This framing follows the compile-mode terminal substrate that landed in PR +#113. `src/process.rs` already owns PTY creation, process groups, bounded +readers, stdin writes, resize, exit/restart state, and final drain. +`src/ansi.rs` already owns a streaming UTF-8/CSI/OSC parser, but deliberately +emits only the line-oriented subset compile-mode needs. Vterm does not replace +either subsystem. It extends their contracts and adds the missing terminal +screen state machine. + +Arc 5 stage 2 ships as three separately reviewed PRs: + +1. **terminal core** — full-screen VT events, `TerminalScreen`, internal + session ownership/contracts, and headless real-PTY acceptance; +2. **TUI integration** — terminal-window composition, input, resize, + scrollback, selection, and copy; +3. **GPU integration** — protocol v19 terminal messages, semantic-daemon + routing, and a native GPU terminal renderer. + +There is no single mega-PR. Each stage is useful and testable by itself, and a +later stage starts only after the preceding stage lands on `main`. + +## 0. Revision 5 — Stage 1 implementation and review record + +The first of the three vterm PRs is implemented, reviewed, fully gated, and +landed on `main` as merge `643d1e1`. Initial feature commit `bbc1f33`, +first-review fixes through `bf972a7`, and second-review hardening `9797ada` +shipped through PR #126, . +It is deliberately headless: there is no `pmacs.terminal` Lua module, +interactive terminal command, TUI paint branch, or GPU/protocol surface yet. + +### 0.1 Public seam and ownership + +`src/terminal/session.rs` exports: + +- owned `TerminalSpec { command, args, cwd, env, name, rows, cols, + scrollback_rows }`, with `new` and strict pre-side-effect `validate`; +- `TerminalProcessState::{Running, Exited(i32), Signaled(String), + Crashed(String)}`; +- owned `TerminalSnapshot { buffer_id, size, cells, cursor, title, + screen_generation, selection, scroll_offset, at_bottom, pid, process }`; +- `SharedTerminalManager = Rc>`; +- `TerminalManager::{new, len, is_empty, open, is_terminal, process_id, + snapshot, tick, send, resize, terminate, prune, shutdown}`. + +The Stage 1 `snapshot(BufferId)` is intentionally context-free: +`selection=[]`, `scroll_offset=0`, and `at_bottom=true`. Stage 2 adds +per-`(FrontendId, WindowId, BufferId)` state and an owned +`snapshot_for_view(...)`; it must not add a second screen. + +`EditorState` owns the one shared manager. Tick order is supervisor `tick` → +terminal-owned PID drain/watchdog/prune → `process.after-tick`; LSP, MCP, and +ordinary Lua process events retain their existing owners. `ProcessSpec` gained +an `AnsiParserProfile`: `ProcessSpec::new` and Lua `ansi=true` stay +`LineOriented`, while terminal sessions explicitly request `FullScreen`. +Synchronous unpublished terminal spawn failure emits no orphan process event. + +Terminal identity buffers are pathless, clean, empty, and buffer-read-only. +The guard runs before direct edits, split Lua begin/skip-intercept edits, +undo/redo, and local/remote CRDT content import. Attaching an immutable empty +CRDT for semantic identity remains valid. + +### 0.2 Stage 1 acceptance mapping + +All fourteen Stage 1 criteria are implemented: + +1. whole/split parser equivalence: + `screen::tests::parser_split_points_produce_identical_screen` plus the ANSI + split matrix; +2. malformed/truncated/over-cap recovery: the 44 `ansi::tests`, including + split UTF-8, ignored CSI/OSC/DCS, and forward-progress cases; +3. cursor/region/edit exactness: + `cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions`; +4. pending wrap/wide/combining invariants: wide overwrite, split ZWJ/RI/ + modifier/variation, right-edge, and continuation tests; +5. alternate/synchronized publication: + `alternate_screen_preserves_main_and_has_no_history`, + `synchronized_output_gates_snapshot_and_finish_releases`, and watchdog; +6. DEC G0/G1 + SI/SO: `acs_and_device_replies_are_exact`; +7. SGR fidelity and ignored attributes: ANSI SGR/color/underline tests plus + screen operation coverage; +8. resize semantics: soft-wrap reflow, wide-boundary/cursor/hard-break tests, + alternate clipping, and atomic invalid resize; +9. dual history limits: `history_obeys_row_and_cell_caps`; +10. bounded DA/DSR/CPR and unsupported-output safety: + `acs_and_device_replies_are_exact` plus parser ignore cases; +11. strict owned specifications: + `strict_owned_spec_rejects_before_spawn_and_is_mutation_independent`; +12. real adversarial PTY/final drain: + `final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained` + splits ESC/CSI writes, observes addressed alternate-screen output while + running, unblocks raw stdin through `send`, restores the main screen, and + proves final output precedes the exact PID/outcome annotation; zero, + non-zero, signal, wrapped, and one-row annotations are separately pinned; +13. lifecycle cleanup: transactional failure, live buffer-kill prune/reap, and + TERM-ignoring editor shutdown acceptance; +14. read-only/CRDT invariants: default + CRDT shared acceptance and focused + buffer unit tests cover every mutation route and empty bootstrap. + +### 0.3 Final gates and bite + +The initial delivery gate run fixed missing acceptance-crate documentation; +PR CI then exposed Darwin's numeric `strsignal` suffix, fixed in `962944b`. +Review round 1's first Clippy pass found only identical LF/IND match arms, +consolidated in `bf972a7`. Review round 2 added one screen unit and one shared +acceptance case; the complete sequence restarted from gate 1: + +- `cargo fmt --check`: clean; +- `cargo clippy --workspace --all-targets -- -D warnings`: clean; +- default library: 1,661 passed, 3 ignored; +- CRDT library: 1,837 passed, 3 ignored; +- Stage 1 acceptance: 9 default + 10 CRDT passed; +- M4 acceptance: 114 passed, 3 ignored, 1 `basedpyright` filtered; +- required GPU: 109 passed; +- workspace: 2,769 passed across 79 suites, 19 ignored, 1 filtered; +- `git diff --check`: clean. + +`scripts/bite main src/lib.rs --test vterm_stage1_acceptance` returned +`bite: OK`: the swapped pre-stage crate root cannot compile the new terminal +API. This is explicitly the helper's weaker compile-time API bite, not a clean +behavioral assertion failure. + +`scripts/bite HEAD^ src/ansi.rs --lib +parser_split_points_produce_identical_screen` returned `bite: OK` with a clean +behavioral assertion failure: the pre-dispatch parser left the cursor at row +zero/column four instead of applying NEL/RI/IND and landing at row one/column +zero. + +`scripts/bite HEAD^ src/terminal/screen.rs --test vterm_stage1_acceptance +terminal_cells_reject_child_control_characters` returned `bite: OK` with a +clean behavioral failure: the pre-hardening screen stored control bytes in a +grapheme cluster rather than preserving the blank snapshot. + +### 0.4 Downstream review findings (not implemented) + +Stage 2 must derive PTY resize ownership from a durable accepted-input/focus +owner before render fan-out, never transient `EditorCore::active_frontend`. +Because `KeyDispatcher` pending state is global, a terminal `C-c` continuation +must carry its owning `FrontendId`. Terminal copy should use the existing core +kill-ring/clipboard setter, while the local run loop must drain/present +clipboard signals; active-terminal BEL likewise uses the out-of-band frontend +signal path. + +Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal +resize/pointer sending and coalescing. Daemon handlers must authenticate source +frontend/buffer ownership before input, resize, or pointer routing. Wire-facing +terminal state, selection, and limits must live in or be re-exported from +`pmacs-protocol`. The current 16 MiB transport frame cap cannot hold the legal +worst complete terminal frame (up to roughly 64 MiB of cluster bytes before +encoding overhead): Stage 3 must either raise and test a measured cap at least +as large as the legal worst case (review estimate at least 80 MiB), or add a +shared aggregate payload bound. It must never silently chunk the locked +complete-frame protocol. + +### 0.5 Stage 1 review round 1 + +The first external review found no ownership, mutation-guard, parser-cap, or +security regressions. This round resolves its three merge-adjacent findings: + +- `ESC D` (IND), `ESC E` (NEL), and `ESC M` (RI) are typed full-screen + operations. RI scrolls down only at the top margin; IND/NEL scroll up at the + bottom margin, with NEL additionally returning to column zero. The parser's + every-byte-split matrix and focused screen-margin test pin the complete path. +- Terminal children no longer inherit an arbitrary host `TERM`; absent a + caller override, their process environment gets `TERM=xterm-256color`. +- The TERM-ignoring shutdown acceptance uses `kill(pid, 0)` through `nix` + instead of Linux-only `/proc`, so macOS now exercises the assertion. +- Resize retains every surviving application tab stop and installs default + stops only in newly added columns. + +`spawn_ansi_parser` intentionally calls `AnsiParser::finish()` on channel +disconnect for both profiles. For existing line-oriented compile/REPL +consumers, EOF therefore delivers trailing partial text and required synthetic +style/alternate-screen balancing that older code dropped. This is an +intentional latent-bug fix and an observable compatibility contract. + +The Stage 2 Lua `open` surface must uniquify colliding default buffer names +(`*terminal:sh*`, `*terminal:sh*<2>`, and so on) before terminal creation +becomes user-visible. + +### 0.6 Stage 1 review round 2 + +The second external review found no ownership, lifecycle, mutation-guard, +transactional-spawn, final-drain, parser-cap, or reflow defects and judged +Stage 1 merge-ready. Its renderer-boundary hardening and cheap cleanups are +resolved before Stage 2: + +- `TerminalScreen::write_text` drops every `char::is_control()` value before + grapheme segmentation, so parser-produced C1 and direct-event C0/C1 bytes + cannot enter copyable or renderable cells. Unit and shared acceptance tests + pin a byte-identical blank snapshot. +- SGR mouse release reports retain the released left/middle/right button code + and use the lowercase `m` final. +- The dead `line_feed` mode parameter and contradictory wide-grapheme branch + are removed; all logical-line ID allocation saturates consistently; and + terminal prune clears stale round-trip input membership. + +Out-of-range DECSTBM bottom clamping, CSI-intermediate clone removal, and a +separately named configuration-time scrollback-row cap remain explicit +deferrals in §11. + +## 1. Problem and ownership boundary + +Pmacs can supervise a PTY and can parse enough ANSI to turn command output into +a line-oriented compilation buffer. It cannot host `nvim`, `htop`, a shell +using cursor motion, or any other application whose output means “mutate a +terminal screen” rather than “append text.” The current parser intentionally +recognizes and discards cursor addressing, alternate-screen state, scrolling, +and most terminal modes. The editor renderer only knows an ordinary rope and +its views. + +The missing abstraction is a real terminal state machine: + +```text +PTY bytes -> AnsiParser -> terminal operations -> TerminalScreen + -> response bytes -> PTY stdin +Frontend input -> terminal input encoder ------------------------^ + +TerminalScreen -> TUI cell composition + -> protocol v19 TerminalFrame -> GPU cell layout +``` + +Ownership is explicit: + +- `ProcessSupervisor` owns the child, PTY file descriptors, bounded worker + pipeline, signal delivery, and final drain. +- `AnsiParser` owns byte-stream framing and escape-sequence decoding. It does + not own a screen. +- `TerminalScreen` owns main/alternate grids, cursor and modes, scroll regions, + tab stops, and scrollback. +- `TerminalManager` owns the mapping from a special editor `BufferId` to one + PTY process plus one `TerminalScreen`, and owns per-window/per-frontend + scroll/selection state. +- The ordinary buffer is an identity and lifecycle anchor only. Terminal + screen contents are never mirrored into its rope. +- TUI and GPU frontends own final glyph drawing. They consume cells; they do + not reinterpret ANSI or maintain a second VT state machine. + +This keeps the existing semantics-down boundary intact. Document text remains +a CRDT/rope semantic surface. A terminal is inherently a cell protocol, so its +new wire family carries terminal cells, not daemon-formatted document text and +not pixels. + +## 2. Ground truth in the current tree + +The implementation must preserve these existing contracts: + +- `ProcessMode::Pty`, `write_stdin`, `resize_pty`, group-directed signals, + bounded output channels, and the TERM/KILL final drain already exist in + `src/process.rs`. +- `ProcessSpec::ansi_events` moves parsing onto a bounded worker and emits + `ProcessEventKind::Ansi`; raw pipe/LSP consumers retain their byte contract. +- `AnsiParser` is stateful across arbitrary feed boundaries, has a per-state + escape-sequence cap, safely recovers malformed sequences, carries truecolor + and underline color, and resets after `finish()`. +- The parser currently emits `Text`, `SetStyle`, line-oriented controls, + `Erase`, `SetTitle`, and alternate-screen markers. Cursor motion and most + CSI/DEC operations are parsed but discarded by design. +- Alternate-screen suppression is currently parser-global and load-bearing for + compile/REPL consumers. Vterm therefore requires an explicit parser profile: + `LineOriented` preserves today's suppression and event contract; + `FullScreen` emits all screen/mode operations. `ProcessSpec` selects the + profile when `ansi_events` is enabled; existing Lua process specs default to + `LineOriented`, while terminal sessions construct `FullScreen` specs. +- `EditorState::tick_processes` is the main-thread process drain point. + LSP/MCP and Lua packages drain events only for process IDs they own. +- `EditorCore::round_trip_buffers` already disables optimistic frontend edits + for special buffer-local input surfaces. +- Grid frontends already receive generic `CellDelta`; a terminal window can be + composed into that grid without a new grid protocol. +- Semantic frontends hold document text through CRDT snapshots and receive + byte-anchored style/decorations. They require a new terminal-specific + message because an empty terminal identity buffer contains no screen text. +- Desktop persistence saves file-backed buffers only. A pathless terminal + buffer is already omitted; no special persistence exception is required. + +## 3. Terminal core model + +### 3.1 Types and files + +Stage 1 adds a `src/terminal/` module rather than growing `editor.rs` or +`ansi.rs` into a second monolith: + +- `screen.rs`: `TerminalScreen`, `TerminalGrid`, `TerminalRow`, cursor, modes, + scrollback, resize, snapshots; +- `input.rs`: normalized key/mouse/paste to VT byte encoding; +- `session.rs`: `TerminalManager`, `TerminalSession`, process ownership and + event application; +- `view.rs` (stage 2): per-context viewport/selection helpers and TUI + composition. + +`src/ansi.rs` remains the one escape parser. It gains terminal operations; it +does not depend on editor state or `TerminalScreen`. + +The public core shape is: + +```rust +pub struct TerminalManager { /* BufferId -> TerminalSession */ } + +pub struct TerminalSelectionSpan { + pub row: u32, + pub start_col: u32, // inclusive + pub end_col: u32, // exclusive +} + +pub enum TerminalProcessState { + Running, + Exited(i32), + Signaled(String), + Crashed(String), +} + +pub struct TerminalSnapshot { + pub buffer_id: BufferId, + pub size: CellSize, + pub cells: Vec, // row-major visible slice + pub cursor: Option, + pub title: Option, + pub screen_generation: u64, + pub selection: Vec, + pub scroll_offset: u32, + pub at_bottom: bool, + pub pid: u32, + pub process: TerminalProcessState, +} +``` + +A snapshot is owned data taken only after all parser events for the current +main-thread tick are applied. Renderers never borrow the mutable screen across +Lua, editor-core, or process-supervisor calls. + +### 3.2 Parser extension + +`AnsiEvent` gains enough operations to drive a VT-style screen: + +- printable text, BEL, CR, LF/VT/FF, BS, HT, and tab-stop set/clear; +- relative and absolute cursor movement (`CUU/CUD/CUF/CUB`, `CNL/CPL`, + `CHA/HPA`, `VPA`, `CUP/HVP`); +- erase display/line/characters; +- insert/delete characters and lines; +- scroll up/down and set/reset scrolling margins; +- save/restore cursor for both DEC and CSI spellings; +- main/alternate screen enter/exit (`47`, `1047`, `1049`); +- mode set/reset for insert, origin, autowrap, application cursor, + application keypad, cursor visibility, bracketed paste, focus reporting, + synchronized output (`?2026`), and supported mouse reporting modes; +- SGR and title changes; +- DEC G0/G1 character-set selection plus SI/SO, including the line-drawing + characters full-screen TUIs depend on; +- device-status/device-attribute queries represented as typed requests. The + session, not the parser, writes bounded response bytes to PTY stdin. + +Unknown, private, or malformed sequences stay non-fatal and bounded. Parser +state always makes forward progress. DCS/APC/PM payloads remain ignored under +the same per-sequence cap; they must not leak into visible text. + +The parser-profile split is compatibility-critical. Full-screen support must +not make compile-mode start appending alternate-screen text or receiving +cursor events it does not understand. `AnsiParser::new()` and the existing +Lua `ansi = true` process option retain the line-oriented profile; terminal +session construction is the only initial full-screen caller. On `finish()`, +`LineOriented` preserves today's synthetic alternate-screen/style balancing. +`FullScreen` flushes pending text but does not invent an alternate-screen exit +or clear cells. The session manager, after applying the actual final parser +events, adds the process-exit annotation described in §4.1. Parser internals +reset in both profiles. + +The shared `Cell::Style` remains unchanged in this arc. Existing support covers +indexed/truecolor foreground/background, bold, italic, underline variants, +underline color, and reverse. Faint, conceal, blink, and strikethrough remain +ignored rather than being mapped to an unrelated attribute. Extending the +shared style would change every cell-carrying postcard encoding and is a +separate protocol-wide decision, not hidden vterm scope. + +### 3.3 Screen invariants + +`TerminalScreen` maintains two grids: + +- **main screen** with bounded scrollback; +- **alternate screen** with no scrollback and an independently saved cursor. + +Every physical row records a monotonic logical-line ID, its cell offset within +that logical line, and whether it ended in a soft autowrap. This is necessary +for copy and resize: hard line breaks become `\n`; soft-wrapped physical rows +are joined into one logical line. Per-view top/selection anchors use +`(line_id, cell_offset)`, not a physical row index, so reflow can remap them. + +Core invariants: + +- `cells.len() == rows * cols`; every row has exactly `cols` cells. +- Cursor and scrolling margins are always in bounds after every operation. +- A wide grapheme occupies a leading glyph plus `Glyph::Continuation`; an + overwrite, erase, insert, delete, or resize never leaves an orphaned half. +- Combining codepoints extend the preceding grapheme when one exists; at the + left edge they combine with a space cell. Cluster byte length is bounded. +- Autowrap uses the pending-wrap rule: writing the last column arms a wrap; + the following printable grapheme performs it. Cursor motion/control clears + the pending wrap where VT behavior requires. +- Origin mode interprets absolute row addressing relative to the active + scrolling region. +- Insert/delete/scroll operations affect only their defined region and fill + exposed cells with the current erase style. +- Entering `1049` saves the main cursor and clears the alternate grid; leaving + it restores the main grid/cursor. Repeated set/reset is idempotent. +- Resizing the main screen reflows soft-wrapped logical lines, preserves hard + breaks and logical-line IDs, and maps the cursor to the corresponding + logical offset. The alternate screen is never reflowed: it is clipped/padded + in place, matching a full-screen application's expectation that it will + repaint after `SIGWINCH`. +- Scrollback eviction removes the oldest complete physical rows. It clamps a + per-view anchor whose logical line was evicted to the oldest retained row. + The default cap is 10,000 rows and an independent 4,000,000-cell budget; + either limit may trigger eviction. + +Synchronized output is a publication gate, not a second screen. Operations +continue mutating `TerminalScreen`, but snapshots retain the last published +generation until `?2026l`. Exit/truncation releases the final state. A bounded +one-second watchdog also releases and clears synchronization if a buggy child +never resets it, preventing a permanently frozen editor surface. + +Hard limits are shared constants, not frontend-local guesses: + +- maximum rows: 512; +- maximum columns: 512; +- maximum visible cells: 262,144; +- maximum UTF-8 bytes in one grapheme cluster: 256; +- maximum retained history cells: 4,000,000; +- maximum parser control-string payload: the existing 1 KiB cap. + +Invalid creation/resize arguments reject atomically. A rejected resize leaves +the process and prior screen unchanged. + +### 3.4 Device responses + +The core responds to the small query set required by normal shells and TUIs: + +- primary and secondary device attributes; +- operating-status report; +- cursor-position report, using current origin semantics. + +Responses are fixed templates plus checked decimal coordinates and pass +through the existing bounded `write_stdin` queue. OSC 52 clipboard writes, +window manipulation, palette mutation, hyperlinks, sixel, and arbitrary DCS +responses are not honored. Child output is untrusted; it cannot directly set +the host clipboard or execute an editor command. + +## 4. Session, buffer, and Lua contract + +### 4.1 Session lifecycle + +One terminal session owns exactly one process ID, one screen, and one identity +buffer. A buffer may appear in several windows/frontends, but there is never a +second parser or screen for it. + +Creation order is transactional: + +1. validate and copy the complete `TerminalSpec`; +2. create the read-only identity buffer; +3. spawn a raw PTY with `ansi_events = FullScreen`; +4. install the session in `TerminalManager`; +5. mark the buffer round-trip-only. + +Stage 1 exposes that operation as a Rust manager contract for headless tests +and future callers, but deliberately registers no interactive command: opening +an unrenderable blank terminal buffer would be a broken partial feature. +Stage 2's Lua binding calls the same operation and switches the caller's active +window only after it succeeds. +On synchronous spawn failure, the temporary buffer is removed and no session +entry remains. The terminal process ID is not exposed through +`pmacs.process`; only `TerminalManager` drains its events, so two consumers +cannot steal batches from each other. + +`EditorState::tick_processes` becomes: + +1. supervisor `tick()`; +2. terminal manager drains only its owned process IDs, applies all ANSI + batches, queues device responses, and records terminal exit state; +3. the existing `process.after-tick` hook runs; +4. LSP/MCP retain their existing later ticks. + +At process exit, final drained output is applied first. The manager then writes +one synthetic, default-style hard line into the active terminal screen: +`Process exited normally with code 0` for zero; `Process exited +abnormally with code ` for a non-zero code; or `Process exited +abnormally with signal ` for signal termination. It emits CRLF first +when needed so the annotation never overwrites child text. The annotation is +terminal-owned (not parser output and not rope text), visible and copyable like +the rest of the screen. +The buffer remains until the user kills it. Killing the buffer terminates a +still-live process/session. A periodic prune handles all buffer-removal paths, +including Lua callers that bypass the friendly terminal close API. Editor +shutdown uses the existing supervisor shutdown path and cannot restart a +terminal. + +The wire-visible process state has only the four variants above: synchronous +spawn failure never publishes a session, and `terminate` remains `Running` +until the supervisor reports its final outcome. Crash/signal strings are +sanitized to one line and bounded before snapshots or Lua metadata are built. + +### 4.2 Buffer semantics + +A terminal buffer is pathless, unmodified, and read-only. Read-only is a +buffer-owned invariant, not only an intercept view: `Buffer` gains a flag and +typed error checked by ordinary edits, intercept-skipping host edits, undo/ +redo, and local/remote CRDT content mutation. The terminal manager sets it +before publishing the session. The rope stays empty for the session lifetime. + +Consequences are deliberate: + +- buffer lists and window switching see a normal named buffer; +- normal save/autosave/LSP/syntax/CRDT editing does not apply; +- desktop save omits it because it has no file path; +- terminal scrollback does not participate in ordinary document search; +- copy reads from terminal rows, never from a hidden mirror rope; +- killing the buffer is the single editor-lifecycle teardown signal. + +Semantic bootstrap may attach an immutable empty CRDT state and send its +`BufferSnapshot` so v18/v19 mirrors can track the buffer identity and +`CursorByte` without decode/state failure. No terminal contents enter that +CRDT, and local/remote CRDT edit validation rejects the read-only buffer. The +stage-3 terminal frame is the authoritative visible semantic surface; a forged +remote operation cannot mutate the empty identity rope. + +No hidden “text projection” is maintained. Two representations would drift on +cursor rewrites, erase operations, alternate-screen swaps, and resize. + +### 4.3 Lua API + +Stage 2 installs `pmacs.terminal` before user config, loads +`builtin/runtime/terminal.lua`, and registers the interactive command: + +```lua +local buffer = pmacs.terminal.open { + command = os.getenv('SHELL') or '/bin/sh', + args = {}, + cwd = nil, -- inherits instance cwd + env = {}, + name = nil, -- default: *terminal:* + rows = 24, + cols = 80, + scrollback_rows = 10000, +} + +pmacs.terminal.is_terminal(buffer) -- boolean +pmacs.terminal.state(buffer) -- fresh plain metadata table +pmacs.terminal.send(buffer, bytes) -- explicit raw bytes +pmacs.terminal.resize(buffer, rows, cols) +pmacs.terminal.terminate(buffer) -- SIGTERM; buffer remains +pmacs.terminal.scroll(lines) -- active terminal window +pmacs.terminal.scroll_to_bottom() +pmacs.terminal.copy_selection() -- active terminal window +``` + +`open` validates exact raw table fields before side effects. Unknown fields, +metatable-provided fields, holes in `args`, non-string env keys/values, +embedded NUL, non-integer dimensions, and out-of-range scrollback reject with +the field named. The copied spec is immune to caller mutation. Returned and +accepted identity is `BufferIdLua`, following the rest of the editor API. + +The built-in chunk registers `terminal` as an interactive command. It opens +`$SHELL` without a shell-command interpolation layer. There is no command +string split and no implicit `sh -c`. +It also installs terminal-buffer-local commands used after the escape prefix: +`M-w` copies the terminal selection, `M-v`/`C-v` page scrollback up/down, and +`M-<`/`M->` move to the oldest retained row/bottom. These shadow ordinary +document commands only during the one-key editor escape; normal terminal input +still sends those keys to the child. + +## 5. Stage 2 — TUI integration + +### 5.1 Composition and cursor + +For every window whose `buffer_id` belongs to `TerminalManager`, the window +content rectangle is painted from a terminal snapshot instead of +`TextView::render`. Modeline/statusline composition remains unchanged. Normal +text overlays, line-number gutters, syntax, diagnostics, and wrapping are not +run over terminal cells. + +Each `(frontend_id, window_id, buffer_id)` owns a `TerminalViewState`. At +bottom, the last screen row aligns with the content rectangle's last row. +Scrolling records a stable logical-line top anchor rather than a numeric +distance from a moving tail. New child output therefore does not move a +scrolled-back viewport or selection. If retention evicts that anchor, it +clamps once to the oldest retained row. A “not at bottom” marker is available +to the built-in terminal statusline provider. + +The active terminal cursor is translated from terminal-local coordinates into +the window rectangle. It is hidden when the child hid it, the window is not +active, the viewport is scrolled away from bottom, or the coordinate is +clipped. Other terminal windows do not paint a cursor. + +A smaller window clips; a larger window pads with default cells. Merely +rendering a passive view never resizes the PTY. + +BEL is forwarded only from the active terminal through the existing frontend +signal path. OSC title is sanitized and exposed in terminal metadata/frame and +the terminal statusline; it does not rename the identity buffer or directly +set the host window title. + +### 5.2 Input precedence + +Modal editor surfaces remain authoritative. Input precedence is: + +1. minibuffer, incremental search, completion/menu, query-replace, and other + existing modal shadows; +2. terminal escape-prefix state; +3. terminal key/mouse/paste handling when the active buffer is terminal; +4. ordinary buffer-local/global keymaps and self-insert. + +All terminal buffers remain in `round_trip_buffers`, so GPU/TUI input reaches +this daemon-owned decision before any optimistic edit. +Escape-prefix state is per frontend, so one attached user's pending escape +never captures another user's next key. + +When terminal input owns a normalized key, `terminal/input.rs` encodes: + +- UTF-8 printable characters; +- Ctrl-character mappings, Alt ESC-prefixing, Enter/Tab/Backspace/Escape; +- arrows/Home/End according to application-cursor mode; +- Insert/Delete/Page and F1–F12 xterm sequences; +- Shift-Tab and supported modifier parameters. + +Unknown/lock/media keys are ignored, never converted into text. Press is the +only actionable event in the current normalized protocol; repeat arrives as +repeated press and release is not forwarded. +The normalized protocol does not distinguish number-row digits from numeric +keypad digits, so application-keypad mode is tracked but cannot transform +those ambiguous `Key::Char` events. + +`C-c` is the fixed stage-2 terminal escape prefix. It is consumed and makes the +next key run through the ordinary editor dispatcher, allowing `C-c C-x ...` +for editor commands. `C-c C-c` sends the literal Ctrl-C byte required to +interrupt the child. This is an intentional fixed stage-2 policy. + +Paste sends exact bytes, wrapped in `ESC[200~` / `ESC[201~` only while the +child enabled bracketed paste. It never passes through a command shell or Lua. +When the child enabled focus reporting, authenticated frontend focus gain/loss +sends `ESC[I` / `ESC[O` for the controlling terminal. With the mode off, +focus changes send no PTY bytes. + +### 5.3 Mouse, selection, copy, and scrollback + +If the child enabled a supported mouse mode, pointer events inside the terminal +content rectangle are encoded as SGR mouse reports, with coordinates translated +to terminal-local 1-based cells. The active mode determines whether press, +release, drag, move, and wheel are reported. + +Otherwise the editor owns the gesture: + +- wheel changes the per-window scrollback offset; +- primary drag creates a terminal-cell selection across history and screen; +- copy serializes selected rows as UTF-8, trims only trailing default blank + cells, joins soft-wrapped rows without `\n`, and separates hard rows with + `\n`; +- wide-cell continuations are never emitted twice; +- a new plain click clears the old selection; +- child output does not move a scrolled-back viewport or selection anchor. + +`pmacs.terminal.copy_selection()` publishes through the existing kill-ring / +clipboard path. Ordinary document selection fields remain untouched. + +### 5.4 Resize ownership + +One PTY has one kernel window size even when displayed in several views. The +controlling view is the active window of `core.active_frontend` — the frontend +that most recently supplied accepted input/focus. Only that view may resize the +PTY. Passive views clip/pad. + +For grid frontends, the daemon derives the terminal content `rows × cols` from +the computed split rectangle and modeline reservation. Focus/split/frontend +resize changes trigger one checked `resize_pty`; unchanged dimensions are +suppressed. The screen model resizes before the child receives `SIGWINCH`, so +its repaint lands into the new geometry. +If the computed content rectangle has zero rows or columns, rendering skips it +and the prior valid PTY size remains unchanged; zero is never sent to +`resize_pty`. + +## 6. Stage 3 — protocol v19 and GPU integration + +### 6.1 Wire additions + +Protocol v19 appends, never inserts, these final variants: + +```rust +InstanceMessage::TerminalFrame { + buffer_id: BufferId, + size: CellSize, + cells: Vec, + cursor: Option, + title: Option, + screen_generation: u64, + selection: Vec, + scroll_offset: u32, + at_bottom: bool, + pid: u32, + process: TerminalProcessState, +} + +FrontendEvent::TerminalResize { + frontend_id: FrontendId, + buffer_id: BufferId, + size: CellSize, +} + +FrontendEvent::TerminalPointer { + frontend_id: FrontendId, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, +} +``` + +`TerminalFrame` is a complete visible-grid replacement. Empty is not a clear +sentinel: valid terminal sizes are non-zero and `cells.len()` must equal area. +Complete replacement is chosen over a second diff/cache protocol for the first +GPU stage. `screen_generation` advances on screen/process/title mutation; +scroll/selection have their own per-context epochs. The producer caches and +compares the complete context payload, so a view-only change still sends even +when `screen_generation` is unchanged, while an identical payload is silent. + +All terminal frame fields are untrusted at the GPU boundary. Validation checks +shared row/column/area limits, exact area, cursor bounds, title length, +selection ordering/non-overlap/bounds, cluster UTF-8 and cluster-byte limits, +continuation structure, and attachment absence. +Invalid input is rejected atomically and the last valid terminal frame remains +painted. + +The daemon routes terminal resize/pointer events by authenticated session +source. Claimed frontend and buffer must match the source's active terminal +window. A mismatch is dropped without resizing, selecting, or writing PTY +input. + +The protocol remains compatible with v18 where structurally possible: + +- v18 grid peers need no new message and continue to receive composed + `CellDelta` terminal windows; +- v18 semantic peers receive the immutable empty identity snapshot but no + terminal variant. They cannot display the terminal screen; terminal use from + those peers is unsupported, while normal document editing remains supported; +- v19 frontends gate the new outbound event variants on negotiated version; +- postcard byte pins cover the old final variants plus the newly appended + discriminants. + +### 6.2 Semantic producer + +When a semantic frontend's active buffer is a terminal, its producer emits +`TerminalFrame` plus the existing global theme/font/statusline facts that still +apply. It suppresses document-only style spans, decorations, inlays, block +adornments, folds, file summaries, line numbers, and document cursor layout for +that buffer. On switching back to a document, existing caches are invalidated +so the first document frame is a full authoritative resync. + +The terminal frame is scoped to the authenticated frontend/window context, +because scrollback offset and selection are per view. A frame for one split or +frontend must never overwrite another context's baseline. + +A GPU frontend reports its terminal viewport in **cells**, computed from its +own font metrics and pixel allocation. No pixel dimensions, glyph advances, +or DPI cross the daemon boundary. The daemon accepts a resize only from the +controlling active frontend defined in §5.4. + +### 6.3 GPU renderer + +The GPU keeps a dedicated terminal render mode keyed by active `buffer_id`. +It does not synthesize rope text from cells. Layout rules: + +- one terminal column equals the active monospace cell advance; +- `Glyph::Continuation` consumes a column and draws nothing; +- clusters shape as one cell origin with the declared one/two-column footprint; +- terminal foreground/background/reverse/style resolve from the cell, not + syntax or UI faces; +- selection spans resolve through `ui.selection` over child cells; cursor + placement comes from terminal snapshot state; +- rows never wrap in the frontend; clipping is by terminal cell bounds; +- status band remains outside the terminal grid; +- theme/font changes invalidate terminal shaping and geometry caches; +- a font-size or window-size change recomputes the cell viewport and sends one + `TerminalResize` after suppression of identical sizes. + +Terminal mouse hit-testing is frontend-local pixel -> terminal cell. The GPU +sends `TerminalPointer`, never a fake source byte offset. + +## 7. Four-agent execution plan + +The vterm roster is fixed at four agents for all three stages. Do not add +review/scout agents; reuse these owners so state-machine decisions stay +coherent. + +| Owner | Stable scope | Primary files | +| --- | --- | --- | +| Lead/integrator | contracts first; `TerminalManager`, buffer/Lua/builtin wiring, cross-surface acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, `tests/vterm_*_acceptance.rs`, docs | +| VT core agent | streaming parser operations, screen state machine, input encoder, model units | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | +| TUI agent | terminal window composition, cursor, per-view scroll/selection/copy, grid input and resize | `src/terminal/view.rs`, owned sections of `src/editor.rs`, focused TUI tests | +| Protocol/GPU agent | v19 types/limits/gates, semantic terminal producer, authenticated daemon routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, owned sections of `src/daemon.rs`, `pmacs-gpu/src/main.rs` | + +Coordination rules: + +- Lead establishes types and method signatures before another lane edits a + caller. +- Strict file ownership. `src/editor.rs` passes from lead to TUI only after + stage-1 construction wiring is settled; `src/daemon.rs` belongs only to the + protocol/GPU lane in stage 3. +- Workers do not update docs, ledgers, branches, or PRs and do not stash, + checkout, rebase, or merge. +- Workers add focused tests in their owned modules. Lead alone owns shared + acceptance files. +- Exact-path staging only; never `git add .`. +- Four agents are the total vterm team, not four implementation workers plus a + lead. + +Per-stage utilization: + +- Stage 1: lead + VT core implement; TUI and protocol/GPU owners review the + snapshot/input contracts against their future consumers. +- Stage 2: TUI implements; VT core owns encoder corrections; lead integrates + lifecycle/acceptance; protocol/GPU owner checks that no TUI-only assumption + enters the snapshot contract. +- Stage 3: protocol/GPU implements; TUI and VT core owners add parity cases in + their existing surfaces; lead integrates and gates. + +## 8. Branch and PR plan + +Stage 1 landed on `main` through PR #126 at merge `643d1e1`. Continue the +approved sequential plan: + +1. create `pmacs-vterm-tui`, branch `vterm-tui`, from post-#126 `main`; + implement, gate, and open the second PR; +2. after Stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from + the new `main`; implement, gate, and open the third PR. + +The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`. +Implementation branches are not stacked across an unmerged parent. This avoids +base-branch deletion/auto-close risk and makes each PR's gate evidence honest. + + +## 9. Acceptance + +### Stage 1 — terminal core + +1. Feed every supported CSI/OSC/DEC sequence, including IND/NEL/RI, at every + byte split; whole-feed and split-feed screens are identical. RI and + forward-index operations additionally pin exact scrolling-margin behavior. +2. Split UTF-8, malformed UTF-8, truncated escape, over-cap control string, + and unknown private sequences recover without panic, unbounded growth, or + visible escape leakage. +3. Cursor absolute/relative movement, save/restore, origin mode, margins, + insert/delete character/line, erase, and scroll mutate only the specified + cells. +4. Autowrap pending state, wide glyphs, combining clusters, overwrite, erase, + and clipping never create orphan continuations. +5. Main/alternate screen swaps preserve the main grid and cursor; alternate + output never enters scrollback. Synchronized output publishes no + intermediate frame and releases on reset, EOF, and watchdog expiry. +6. DEC line drawing through G0/G1 + SI/SO renders the expected Unicode box + glyphs across split feeds. +7. SGR indexed/truecolor/underline/reverse survives screen operations; ignored + attributes leave supported fields unchanged. +8. Main-screen resize reflows only soft wraps, preserves cursor/logical-line + identity and application tab stops, and adds defaults only in new columns; + alternate-screen resize clips/pads without reflow. +9. Scrollback obeys both row and cell budgets, evicts oldest rows, and keeps + the visible grid exact. +10. DA/DSR/CPR query events produce bounded exact response bytes; unsupported + OSC/DCS cannot write stdin or clipboard. +11. Strict owned `TerminalSpec` values reject before spawn and are + mutation-independent after spawn. +12. A real PTY child prints cursor-addressed/alternate-screen content in + adversarial chunks; final output lands first, then exact normal/non-zero/ + signal process annotations are visible and copyable before exit state. +13. Spawn failure leaves no buffer/session/process residue. Killing a live + terminal buffer terminates it; editor shutdown leaves no child/reader. +14. Every rope mutation path (ordinary, intercept-skipping, undo/redo, local + or remote CRDT edit/import) rejects the read-only terminal buffer and + leaves its rope/revision/modified state unchanged; immutable empty CRDT + bootstrap remains valid. + +### Stage 2 — TUI + +15. Lua `open` performs the same strict raw-field validation, publishes no + partial state on error, and switches the active window only after success. +16. A terminal window paints exact cells/styles inside its content rectangle; + statusline, sibling splits, and outside cells are untouched. +17. Active cursor translation, child-hidden cursor, passive window, clipping, + and scrolled-back hiding are exact. +18. Printable, Ctrl, Alt, arrows, Home/End, function keys, application cursor, + focus reporting, and unknown keys produce the specified PTY bytes. +19. `C-c` dispatches one editor key; `C-c C-c` sends Ctrl-C; modal minibuffer, + search, menu, and query-replace remain authoritative. +20. Paste is byte-exact with bracketed wrappers only when enabled. +21. Mouse-reporting modes receive translated SGR reports. With reporting off, + the same gestures scroll/select/copy and write no PTY bytes. +22. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, + resize/reflow, eviction-clamped anchors, and selections crossing + history/screen exactly once. +23. The controlling active view alone resizes the PTY; passive split/frontend + renders never cause resize thrash. +24. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, + resizes, scrolls/copies, exits, and restores the host terminal cleanly. + +### Stage 3 — GPU/protocol + +25. Protocol v19 appends all new variants after v18 pins; v18 grid traffic + round-trips unchanged and new outbound variants are version-gated. +26. Terminal frame validation accepts exact shared boundaries and atomically + rejects over-area, bad area, out-of-bounds cursor, malformed cluster, + orphan continuation, invalid selection spans, attachment, overlong title, + and overlong process-state text while retaining the prior valid frame. +27. Semantic terminal activation suppresses document-only messages; switching + back forces a complete document resync. +28. Two frontends/splits on one terminal keep independent scroll/selection + snapshots; only the active controlling context resizes or writes input. +29. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect + another terminal or process. +30. Headless GPU rendering pins background rectangles, indexed/truecolor, + reverse, wide/combining cells, clipping, cursor visibility, status-band + separation, and no frontend wrapping. +31. Font/window resize emits cell dimensions, never pixels, and identical + resize requests are suppressed. +32. Theme/font/terminal generation changes invalidate exactly the affected + caches; an unchanged terminal frame produces no redraw message. +33. A real daemon + required-GPU smoke runs a full-screen alternate-screen + probe, handles input and resize, exits, and returns to the preserved main + screen. + +## 10. Gates and bite verification + +Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its +stage acceptance suite. Stage 2 includes a real hermetic TUI PTY smoke; stage 3 +includes `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` and the real-daemon GPU +probe. + +New behavioral acceptance must be bite-verified against the immediate +pre-stage tree with `scripts/bite` where the swapped files compile. Protocol +v19 tests additionally pin postcard bytes and verify the older-version daemon +filter; a test that merely fails to decode on old code is not a useful bite. + +## 11. Explicit deferrals + +Not part of these three PRs: + +- terminal image protocols (sixel, kitty graphics, iTerm images); +- OSC 52 host clipboard writes and OSC 8 hyperlink interaction; +- faint, blink, conceal, and strikethrough additions to shared `Style`; +- kitty keyboard protocol, key release events, media keys, and IME preedit; +- cursor-shape/blink rendering and numeric-keypad distinction absent from the + current normalized input/cursor protocol; +- RIS (`ESC c`), DECALN (`ESC # 8`), and DEC cursor save/restore mode `?1048`; +- CUU/CUD region clamping when the cursor starts inside scroll margins while + origin mode is disabled; +- combining a character into the preceding cell across intervening SGR or + cursor-control events; +- DECSTBM clamping when an explicit bottom margin exceeds the current screen + height; the current core leaves the existing scrolling region unchanged; +- exact xterm `?1047` clear-on-exit and scroll-margin preservation across + alternate-screen switches; +- legacy X10 mouse byte encoding when a child enables mouse tracking without + SGR mode; Stage 2 sends no report for that unsupported combination; +- nonstandard `CSI 3 K` ignore semantics (the current core clears the line); +- the ASCII fast path that avoids grapheme-candidate allocation and + segmentation for every printable character after another ASCII character, + and avoiding the per-sequence `intermediates` clone in CSI dispatch; +- cleanup of the defensive impossible-state path where a terminal spawn + returns a process without a running PID, and borrow-tolerant `EditorState` + drop; normal spawn/rollback/prune/shutdown paths remain covered; +- a separately named configuration-time scrollback-row cap; the current + validation conservatively reuses the history-cell cap before the runtime + row and cell budgets enforce the effective limit; +- shell integration, prompt marks, command semantic zones, and cwd reporting; +- ordinary document search over terminal history; +- terminal session persistence/reconnect across editor restart; +- reparenting a live terminal process into a second daemon instance; +- user-configurable escape key and scrollback policy. + +Deferral means graceful ignore or documented absence, never escape leakage, +panic, unbounded allocation, or child leak. + +## 12. Resolved decisions + +The 2026-07-21 architecture discussion resolved every Revision 1 question: + +1. Fixed terminal editor escape: `C-c`; `C-c C-c` sends literal Ctrl-C. +2. Resize: reflow main-screen soft wraps; clip/pad alternate screen. +3. Exit: retain the buffer and append the process PID/outcome line from §4.1. +4. Compatibility: additive v19; v18 grid remains supported, v18 semantic has + no terminal surface. +5. GPU wire: complete visible frames with complete-payload suppression. +6. Style: preserve the shared encoding and defer unsupported attributes. +7. Identity: one process/screen per terminal `BufferId`; the most recently + active frontend's active view controls PTY size. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3012479..4da619e 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -37,10 +37,12 @@ use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, - InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, - SelectionSnapshot, StyleSegment, StyleSpan, + InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment, + StyleSpan, cell::{Color as CellColor, Style as CellStyle}, - is_builtin_pair_char, + is_builtin_pair_char, is_modeline_face_name, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; @@ -874,19 +876,17 @@ struct State { squiggle_vertex_buffer: ReusableVertexBuffer, caret_vertex_buffer: ReusableVertexBuffer, minimap_vertex_buffer: ReusableVertexBuffer, - /// Q#S2 — the status band's one-line text. Shaped only when the - /// composed status string changes; rendered as a second - /// `TextArea` in the same prepare pass as the main buffer. + /// Q#S2/Q#SL10 — the status band's shaped right rich text. status_buffer: Buffer, - /// The string `status_buffer` currently holds, for change - /// detection. - status_text: String, - /// Q#S2 — the band's left side (buffer name + modified dot), - /// its own buffer so it left-aligns independently of the - /// right-aligned readout. + /// Rich runs currently installed in the right status buffer. + /// `None` is the invalidation sentinel; an empty vector is valid. + status_runs: Option>, + /// The independently left-aligned status buffer. status_left_buffer: Buffer, - /// Change-detection twin of `status_text` for the left side. - status_left_text: String, + /// Rich runs currently installed in the left status buffer. + status_left_runs: Option>, + /// Latest atomically validated custom statusline replacement. + statusline_segments: Option, /// Q#S1 — the wire-authoritative status facts (protocol v8). status_facts: Option, /// Q#SR5 — the live incremental-search prompt (protocol v9), or @@ -980,6 +980,57 @@ fn completion_kind_glyph(kind: u8) -> char { } } +/// Latest validated custom statusline replacement (Q#SL7/Q#SL10). +#[derive(Clone, Debug, PartialEq, Eq)] +struct StatuslineSegmentsLocal { + buffer_id: BufferId, + left: Vec, + right: Vec, +} +/// Validate the complete untrusted statusline payload before any state +/// changes. Numeric and namespace policy lives only in pmacs-protocol. +fn validate_statusline_segments( + left: &[StatuslineSegment], + right: &[StatuslineSegment], +) -> Result<(), &'static str> { + let count = left + .len() + .checked_add(right.len()) + .ok_or("segment count overflow")?; + if count > MAX_STATUSLINE_PROVIDERS { + return Err("too many segments"); + } + + let mut total_text_bytes = 0usize; + for segment in left.iter().chain(right) { + if segment.text.is_empty() { + return Err("empty segment text"); + } + if segment.text.len() > MAX_STATUSLINE_SEGMENT_BYTES { + return Err("segment text too long"); + } + if segment.text.chars().any(char::is_control) { + return Err("segment text contains a control character"); + } + total_text_bytes = total_text_bytes + .checked_add(segment.text.len()) + .ok_or("total text length overflow")?; + if total_text_bytes > MAX_STATUSLINE_TOTAL_TEXT_BYTES { + return Err("total segment text too long"); + } + if segment.face.len() > MAX_STATUSLINE_FACE_BYTES { + return Err("segment face too long"); + } + if segment.face.chars().any(char::is_control) { + return Err("segment face contains a control character"); + } + if !is_modeline_face_name(&segment.face) { + return Err("segment face is outside ui.modeline"); + } + } + Ok(()) +} + /// The wire-authoritative status facts (Q#S1, protocol v8; `message` /// since v15), mirrored from `InstanceMessage::StatusFacts`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -2172,6 +2223,7 @@ impl State { Some(config.width as f32), Some(fm.status_band_height()), ); + status_buffer.set_wrap(&mut font_system, Wrap::None); let mut status_left_buffer = Buffer::new( &mut font_system, Metrics::new(fm.status_font_size(), fm.status_line_height()), @@ -2181,6 +2233,7 @@ impl State { Some(config.width as f32), Some(fm.status_band_height()), ); + status_left_buffer.set_wrap(&mut font_system, Wrap::None); let mut menu_buffer = Buffer::new( &mut font_system, Metrics::new(fm.menu_font_size(), fm.menu_line_height()), @@ -2294,9 +2347,10 @@ impl State { caret_vertex_buffer: ReusableVertexBuffer::new(), minimap_vertex_buffer: ReusableVertexBuffer::new(), status_buffer, - status_text: String::new(), + status_runs: None, status_left_buffer, - status_left_text: String::new(), + status_left_runs: None, + statusline_segments: None, status_facts: None, search_prompt: None, minibuffer: None, @@ -2792,6 +2846,9 @@ impl State { self.search_prompt = None; self.menu = None; self.status_facts = None; + self.statusline_segments = None; + self.status_runs = None; + self.status_left_runs = None; self.cursor_fresh = false; self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; @@ -3042,8 +3099,8 @@ impl State { // would keep stale colors indefinitely without this. InstanceMessage::ThemeFacts { faces } => { self.faces = faces.into_iter().map(|f| (f.name, f.style)).collect(); - self.status_text.clear(); - self.status_left_text.clear(); + self.status_runs = None; + self.status_left_runs = None; self.request_redraw(); None } @@ -3262,6 +3319,30 @@ impl State { self.request_redraw(); None } + // Arc 4 stage 3 (Q#SL7/Q#SL10) — validate the entire + // untrusted replacement before changing either side. + InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + } => { + if let Err(reason) = validate_statusline_segments(&left, &right) { + eprintln!("pmacs-gpu: ignoring invalid StatuslineSegments: {reason}"); + return None; + } + let next = StatuslineSegmentsLocal { + buffer_id, + left, + right, + }; + if self.statusline_segments.as_ref() != Some(&next) { + self.statusline_segments = Some(next); + self.status_runs = None; + self.status_left_runs = None; + self.request_redraw(); + } + None + } // Arc 4 stage 2 (framing Q#F6/Q#F7) — the global font // preference. Authoritative per attachment: `(None, None)` // is a real reset to the sanitized defaults, never @@ -3941,14 +4022,10 @@ impl State { Some(self.face_wash_or(name, fallback)) } - /// The band's left-segment text color, mirroring - /// [`Self::compose_status_left`]'s priority: minibuffer/isearch - /// content follows `ui.minibuffer`, a transient message follows - /// `ui.statusline`, and the buffer name follows `ui.modeline` - /// (the framing's content-class applicability, Q#TH3). + /// The band's left-segment text color, mirroring the content + /// precedence in [`Self::compose_status_left_runs`]. fn status_left_color(&self) -> Color { - const LEFT_DEFAULT: (u8, u8, u8) = (200, 200, 210); - let fallback = Color::rgb(LEFT_DEFAULT.0, LEFT_DEFAULT.1, LEFT_DEFAULT.2); + let fallback = Color::rgb(200, 200, 210); if self.minibuffer.is_some() || self .search_prompt @@ -3965,39 +4042,76 @@ impl State { if has_message { return self.face_fg_or("ui.statusline", fallback); } - self.modeline_face_colors().map_or(fallback, |(_, t)| t) + self.modeline_face_colors() + .map_or(fallback, |(_, text)| text) } - /// Compose the status-band readout (Q#S1): diagnostic counts - /// (wire-authoritative, severity-colored, omitted when zero), - /// then cursor L:C from the *optimistic* caret (so it tracks - /// typing bursts instead of lagging a round trip), then the - /// All/Top/Bot/NN% scroll indicator. Returns the colored spans. - fn compose_status_spans(&self) -> Vec<(String, Option)> { - use std::fmt::Write as _; - let mut spans: Vec<(String, Option)> = Vec::new(); - if let Some(facts) = self - .status_facts + fn status_right_base_color(&self) -> Color { + self.modeline_face_colors() + .map_or(Color::rgb(168, 168, 180), |(_, text)| text) + } + + /// Resolve an exact custom face against `ThemeFacts`. The producer + /// already normalizes custom entries to an {fg}-only style; absent + /// entries, `ui.modeline`, and defensive `Default` all select the + /// effective base modeline color. + fn status_segment_color(&self, face: &str, base: Color) -> Color { + if face == "ui.modeline" { + return base; + } + self.faces + .get(face) + .and_then(|style| cell_color_to_glyphon(style.fg)) + .unwrap_or(base) + } + + fn current_statusline_segments(&self) -> Option<&StatuslineSegmentsLocal> { + self.statusline_segments .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) - { - if facts.diag_errors > 0 { - // Themes Q#TH5: the counters follow the diag faces - // (fg mask; the shaping-cache invalidation in the - // ThemeFacts arm makes a recolor with constant counts - // actually re-shape, Q#TH8). - spans.push(( - format!("E:{}", facts.diag_errors), - Some(self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76))), - )); - } - if facts.diag_warnings > 0 { - spans.push(( - format!("W:{}", facts.diag_warnings), - Some(self.diag_face_fg_or("ui.diag.warning", Color::rgb(245, 245, 67))), + .filter(|segments| Some(segments.buffer_id) == self.current_buffer_id) + } + + /// Compose the protected right group. Custom providers precede the + /// legacy diagnostic/cursor/scroll suffix. Custom boundaries are one + /// base-colored space; the built-in suffix retains its exact two-space + /// separators. + fn compose_status_runs(&self) -> Vec<(String, Color)> { + use std::fmt::Write as _; + + let base = self.status_right_base_color(); + let mut runs = Vec::new(); + if let Some(custom) = self.current_statusline_segments() { + for segment in &custom.right { + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + runs.push(( + segment.text.clone(), + self.status_segment_color(&segment.face, base), )); } } + + let mut builtins = Vec::new(); + if let Some(facts) = self + .status_facts + .as_ref() + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) + { + if facts.diag_errors > 0 { + builtins.push(( + format!("E:{}", facts.diag_errors), + self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76)), + )); + } + if facts.diag_warnings > 0 { + builtins.push(( + format!("W:{}", facts.diag_warnings), + self.diag_face_fg_or("ui.diag.warning", Color::rgb(245, 245, 67)), + )); + } + } + let mut readout = String::new(); let mut cursor_row = self.scroll_top; if let Some(own) = self.own_cursor @@ -4009,14 +4123,14 @@ impl State { ); let line = self .current_line_starts - .partition_point(|&s| s as usize <= byte) + .partition_point(|&start| start as usize <= byte) .saturating_sub(1); cursor_row = line; - let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; + let line_start = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; let col = self .current_text - .get(ls..byte) - .map_or(0, |s| s.chars().count()); + .get(line_start..byte) + .map_or(0, |text| text.chars().count()); let _ = write!(readout, "L{}:C{}", line + 1, col + 1); readout.push_str(" "); } @@ -4026,90 +4140,107 @@ impl State { self.current_line_starts.len(), cursor_row, )); - spans.push((readout, None)); - spans + builtins.push((readout, base)); + + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + for (index, builtin) in builtins.into_iter().enumerate() { + if index > 0 { + runs.push((" ".to_owned(), base)); + } + runs.push(builtin); + } + runs } - /// The band's left side. While an incremental search is running - /// (Q#SR5) it shows `I-search: (n/m)` — the prompt takes - /// over the band like Emacs's echo area, returning to the buffer - /// name + modified dot (v8 `StatusFacts`) when the search ends. - fn compose_status_left(&self) -> String { - // Q#MB1 — an open minibuffer takes over the band: prompt + input - // (the candidates render separately as a dropdown). Measured by - // the band caret, so it must stay exactly `prompt + input`. - if let Some(mb) = self.minibuffer.as_ref() { - return format!("{}{}", mb.prompt, mb.input); + /// Compose the left group. Minibuffer, isearch, and transient + /// messages suppress custom left segments; ordinary buffer identity + /// starts at the leading edge but may be fully clipped by the right group. + fn compose_status_left_runs(&self) -> Vec<(String, Color)> { + if let Some(minibuffer) = self.minibuffer.as_ref() { + return vec![( + format!("{}{}", minibuffer.prompt, minibuffer.input), + self.status_left_color(), + )]; } - if let Some(sp) = self + if let Some(search) = self .search_prompt .as_ref() - .filter(|s| Some(s.buffer_id) == self.current_buffer_id) + .filter(|search| Some(search.buffer_id) == self.current_buffer_id) { - let label = if sp.regex { + let label = if search.regex { "Regex I-search: " } else { "I-search: " }; - let count = if sp.query.is_empty() { + let count = if search.query.is_empty() { String::new() - } else if sp.invalid { - " [invalid]".to_string() - } else if sp.total == 0 { - " [no match]".to_string() + } else if search.invalid { + " [invalid]".to_owned() + } else if search.total == 0 { + " [no match]".to_owned() } else { - format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) + format!( + " ({}/{})", + search.active.map_or(0, |active| active + 1), + search.total + ) }; - return format!("{}{}{}", label, sp.query, count); + return vec![( + format!("{label}{}{count}", search.query), + self.status_left_color(), + )]; } - // A transient status message (v15 `StatusFacts.message` — LSP - // command summaries like "12 references", error reports) takes - // the band over echo-area style; the daemon clears it on the - // next keypress, which ships a fresh `StatusFacts` and returns - // the band to the buffer name. - if let Some(msg) = self + if let Some(message) = self .status_facts .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) - .and_then(|f| f.message.as_deref()) + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) + .and_then(|facts| facts.message.as_deref()) { - return msg.to_owned(); + return vec![(message.to_owned(), self.status_left_color())]; } - match self + + let base = self.status_left_color(); + let identity = match self .status_facts .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) { Some(facts) if facts.modified => format!("{} ●", facts.name), Some(facts) => facts.name.clone(), None => String::new(), + }; + let mut runs = Vec::new(); + if !identity.is_empty() { + runs.push((identity, base)); } + if let Some(custom) = self.current_statusline_segments() { + for segment in &custom.left { + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + runs.push(( + segment.text.clone(), + self.status_segment_color(&segment.face, base), + )); + } + } + runs } - /// Re-shape the status-band text iff the composed content - /// changed (short lines — shaping is trivial, but not free per - /// frame). + /// Re-shape only when the complete ordered rich-run key changes. + /// Cache advancement follows successful installation and shaping. fn refresh_status_line(&mut self) { - let spans = self.compose_status_spans(); - let composed: String = spans - .iter() - .map(|(t, _)| t.as_str()) - .collect::>() - .join(" "); + let right = self.compose_status_runs(); + let left = self.compose_status_left_runs(); let family = self.resolved_family.clone(); let default_attrs = Attrs::new().family(Family::Name(&family)); - if composed != self.status_text { - let mut rich: Vec<(&str, Attrs)> = Vec::new(); - for (i, (t, c)) in spans.iter().enumerate() { - if i > 0 { - rich.push((" ", default_attrs.clone())); - } - let attrs = match c { - Some(color) => default_attrs.clone().color(*color), - None => default_attrs.clone(), - }; - rich.push((t.as_str(), attrs)); - } + + if self.status_runs.as_ref() != Some(&right) { + let rich = right + .iter() + .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); self.status_buffer.set_rich_text( &mut self.font_system, rich, @@ -4119,20 +4250,22 @@ impl State { ); self.status_buffer .shape_until_scroll(&mut self.font_system, false); - self.status_text = composed; + self.status_runs = Some(right); } - let left = self.compose_status_left(); - if left != self.status_left_text { - self.status_left_buffer.set_text( + if self.status_left_runs.as_ref() != Some(&left) { + let rich = left + .iter() + .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); + self.status_left_buffer.set_rich_text( &mut self.font_system, - &left, + rich, &default_attrs, Shaping::Advanced, None, ); self.status_left_buffer .shape_until_scroll(&mut self.font_system, false); - self.status_left_text = left; + self.status_left_runs = Some(left); } } @@ -5006,20 +5139,22 @@ impl State { } else { selected_advance }; - // Rows stay rows: idempotent no-wrap on the popup buffers - // (assembly set it; a set_wrap no-op costs a comparison). + // Every row-oriented surface stays one row across the metric + // transaction, including the two status buffers (Q#SL10). + self.status_buffer + .set_wrap(&mut self.font_system, Wrap::None); + self.status_left_buffer + .set_wrap(&mut self.font_system, Wrap::None); self.menu_buffer.set_wrap(&mut self.font_system, Wrap::None); self.mb_buffer.set_wrap(&mut self.font_system, Wrap::None); self.completion_buffer .set_wrap(&mut self.font_system, Wrap::None); // Metrics + current dimensions atomically on all seven. self.sync_buffer_dimensions(); - // The two string-equality shaping gates (the popups rebuild - // unconditionally per frame). NUL can never equal a composed - // status string, so the next frame re-shapes with new attrs - // even when its composed text is unchanged. - "\0".clone_into(&mut self.status_text); - "\0".clone_into(&mut self.status_left_text); + // Colors and family are attrs embedded in the status buffers. + // `None` forces the next frame to install and shape rich runs. + self.status_runs = None; + self.status_left_runs = None; // Attrs-bearing reshape at the retained scroll (reshape // normalizes it against the FINAL family/metrics/dims). self.reshape(); @@ -5315,15 +5450,15 @@ impl State { let after_minimap = debug_frame().then(std::time::Instant::now); let text_bounds_right = self.text_bounds_right(); - // Right-align the status readout: measure the shaped width - // and place the area flush to the right pad (Q#S2). + // Right-align from the true full shaped width. An over-wide + // custom prefix may put this origin left of the surface; bounds + // clip it while the protected suffix remains pinned. let status_width = self .status_buffer .layout_runs() - .map(|r| r.line_w) + .map(|run| run.line_w) .fold(0.0_f32, f32::max); - let status_left = - (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); + let status_left = self.config.width as f32 - STATUS_TEXT_PAD - status_width; let status_top = text_area_bottom(self.config.height, self.fm) + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the @@ -5393,8 +5528,8 @@ impl State { bounds: TextBounds { left: 0, top: text_area_bottom(self.config.height, self.fm).round() as i32, - // Stop before the right-aligned readout. - right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32, + // Stop at the right group's actual origin. + right: status_left.max(0.0).round() as i32, bottom: self.config.height.cast_signed(), }, // Themes Q#TH3: the left segment's face follows @@ -6679,6 +6814,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::CompletionPopup { .. } => "CompletionPopup", InstanceMessage::ThemeFacts { .. } => "ThemeFacts", InstanceMessage::FontFacts { .. } => "FontFacts", + InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", } } @@ -9504,6 +9640,493 @@ mod tests { } bounds } + fn statusline_segment(text: impl Into, face: impl Into) -> StatuslineSegment { + StatuslineSegment { + text: text.into(), + face: face.into(), + } + } + + fn apply_statusline( + state: &mut State, + buffer_id: BufferId, + left: Vec, + right: Vec, + ) { + let _ = state.apply_attach_message(InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + }); + } + + fn status_facts(buffer_id: BufferId, message: Option<&str>) -> StatusFactsLocal { + StatusFactsLocal { + buffer_id, + name: "main.rs".to_owned(), + modified: true, + diag_errors: 1, + diag_warnings: 2, + message: message.map(str::to_owned), + } + } + + #[test] + fn statusline_wire_validation_is_atomic_and_accepts_exact_boundaries() { + let Some(mut state) = headless_or_skip(420, 260, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("valid", "ui.modeline.good")], + vec![statusline_segment("right", "ui.modeline")], + ); + let valid_frame = state.render_offscreen(); + let valid_state = state + .statusline_segments + .clone() + .expect("valid payload installed"); + let valid_right_cache = state.status_runs.clone(); + let valid_left_cache = state.status_left_runs.clone(); + + let invalid_payloads = vec![ + (vec![statusline_segment("", "ui.modeline")], Vec::new()), + ( + vec![statusline_segment( + "x".repeat(MAX_STATUSLINE_SEGMENT_BYTES + 1), + "ui.modeline", + )], + Vec::new(), + ), + ( + vec![statusline_segment("bad\ntext", "ui.modeline")], + Vec::new(), + ), + ( + vec![statusline_segment( + "bad-face", + format!("ui.modeline.{}", "x".repeat(MAX_STATUSLINE_FACE_BYTES)), + )], + Vec::new(), + ), + ( + vec![statusline_segment("bad-face", "ui.modeline.\u{7f}")], + Vec::new(), + ), + ( + vec![statusline_segment("wrong-family", "ui.statusline")], + Vec::new(), + ), + ( + (0..=MAX_STATUSLINE_PROVIDERS) + .map(|index| statusline_segment(format!("s{index}"), "ui.modeline")) + .collect(), + Vec::new(), + ), + ]; + for (left, right) in invalid_payloads { + apply_statusline(&mut state, buffer_id, left, right); + assert_eq!(state.statusline_segments.as_ref(), Some(&valid_state)); + assert_eq!(state.status_runs, valid_right_cache); + assert_eq!(state.status_left_runs, valid_left_cache); + assert_eq!( + state.render_offscreen(), + valid_frame, + "a rejected replacement must retain the prior frame byte-for-byte" + ); + } + + let max_face = format!( + "ui.modeline.{}", + "f".repeat(MAX_STATUSLINE_FACE_BYTES - "ui.modeline.".len()) + ); + let boundary: Vec<_> = (0..MAX_STATUSLINE_PROVIDERS) + .map(|_| statusline_segment("x".repeat(MAX_STATUSLINE_SEGMENT_BYTES), &max_face)) + .collect(); + assert_eq!( + boundary + .iter() + .map(|segment| segment.text.len()) + .sum::(), + MAX_STATUSLINE_TOTAL_TEXT_BYTES + ); + apply_statusline(&mut state, buffer_id, boundary, Vec::new()); + let installed = state.statusline_segments.as_ref().expect("boundary valid"); + assert_eq!(installed.left.len(), MAX_STATUSLINE_PROVIDERS); + assert_eq!(installed.left[0].face.len(), MAX_STATUSLINE_FACE_BYTES); + } + + #[test] + fn buffer_snapshot_clears_statusline_mirror_but_keeps_theme_facts() { + let Some(mut state) = headless_or_skip(320, 240, "same") else { + return; + }; + let first = BufferId::next(); + state.current_buffer_id = Some(first); + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(10, 20, 30), + ..CellStyle::default() + }, + )], + ); + apply_statusline( + &mut state, + first, + vec![statusline_segment("old", "ui.modeline.custom")], + Vec::new(), + ); + let _ = state.render_offscreen(); + assert!(state.statusline_segments.is_some()); + + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, "same") + .expect("snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: BufferId::next(), + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("snapshot"), + }); + assert!(state.statusline_segments.is_none()); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + assert!(state.faces.contains_key("ui.modeline.custom")); + } + + #[test] + fn statusline_rich_runs_preserve_builtins_separators_and_face_changes() { + let Some(mut state) = headless_or_skip(500, 280, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + apply_faces( + &mut state, + vec![ + theme_face( + "ui.modeline.red", + CellStyle { + fg: CellColor::Rgb(230, 20, 30), + ..CellStyle::default() + }, + ), + theme_face( + "ui.modeline.green", + CellStyle { + fg: CellColor::Rgb(20, 220, 40), + ..CellStyle::default() + }, + ), + ], + ); + apply_statusline( + &mut state, + buffer_id, + vec![ + statusline_segment("L1", "ui.modeline.red"), + statusline_segment("L2", "ui.modeline"), + ], + vec![ + statusline_segment("R1", "ui.modeline.green"), + statusline_segment("R2", "ui.modeline"), + ], + ); + + let left = state.compose_status_left_runs(); + let right = state.compose_status_runs(); + let left_text: String = left.iter().map(|(text, _)| text.as_str()).collect(); + let right_text: String = right.iter().map(|(text, _)| text.as_str()).collect(); + assert_eq!(left_text, "main.rs ● L1 L2"); + assert_eq!(right_text, "R1 R2 E:1 W:2 L1:C1 All"); + let left_base = state.status_left_color(); + assert_eq!(left[1], (" ".to_owned(), left_base)); + assert_eq!(left[3], (" ".to_owned(), left_base)); + let right_base = state.status_right_base_color(); + assert_eq!(right[1], (" ".to_owned(), right_base)); + assert_eq!(right[3], (" ".to_owned(), right_base)); + assert_eq!(right[5], (" ".to_owned(), right_base)); + assert_eq!(right[7], (" ".to_owned(), right_base)); + assert_eq!( + left[2].1, + Color::rgb(230, 20, 30), + "custom text takes the exact ThemeFacts foreground" + ); + assert_eq!(right[0].1, Color::rgb(20, 220, 40)); + + let _ = state.render_offscreen(); + let before_text: String = state + .status_left_runs + .as_ref() + .expect("left shaped") + .iter() + .map(|(text, _)| text.as_str()) + .collect(); + apply_statusline( + &mut state, + buffer_id, + vec![ + statusline_segment("L1", "ui.modeline.green"), + statusline_segment("L2", "ui.modeline"), + ], + vec![ + statusline_segment("R1", "ui.modeline.green"), + statusline_segment("R2", "ui.modeline"), + ], + ); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let _ = state.render_offscreen(); + let after = state.status_left_runs.as_ref().expect("left reshaped"); + assert_eq!( + after + .iter() + .map(|(text, _)| text.as_str()) + .collect::(), + before_text, + "changing only the face name keeps concatenated text constant" + ); + assert_eq!(after[2].1, Color::rgb(20, 220, 40)); + } + + #[test] + fn modal_left_precedence_suppresses_custom_left_but_preserves_right() { + let Some(mut state) = headless_or_skip(420, 260, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("CUSTOM-L", "ui.modeline")], + vec![statusline_segment("CUSTOM-R", "ui.modeline")], + ); + assert!(state.compose_status_left_runs()[2].0.contains("CUSTOM-L")); + let ordinary_right = state.compose_status_runs(); + + state.minibuffer = Some(MinibufferLocal { + prompt: "M-x ".to_owned(), + input: "find".to_owned(), + cursor: 4, + candidates: Vec::new(), + selected: None, + total: 0, + }); + assert_eq!(state.compose_status_left_runs()[0].0, "M-x find"); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.minibuffer = None; + state.search_prompt = Some(SearchPromptLocal { + buffer_id, + query: "needle".to_owned(), + active: Some(0), + total: 1, + regex: false, + invalid: false, + }); + assert_eq!( + state.compose_status_left_runs()[0].0, + "I-search: needle (1/1)" + ); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.search_prompt = None; + state.status_facts = Some(status_facts(buffer_id, Some("CUSTOM-L"))); + assert_eq!(state.compose_status_left_runs()[0].0, "CUSTOM-L"); + assert_eq!(state.compose_status_left_runs().len(), 1); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.status_facts = Some(status_facts(buffer_id, None)); + assert!(state.compose_status_left_runs()[2].0.contains("CUSTOM-L")); + } + + #[test] + fn theme_recolor_invalidates_both_rich_caches_and_repaints_custom_text() { + let (width, height) = (420, 260); + let Some(mut state) = headless_or_skip(width, height, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("RECOLOR", "ui.modeline.custom")], + vec![statusline_segment("RECOLOR", "ui.modeline.custom")], + ); + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(240, 10, 20), + ..CellStyle::default() + }, + )], + ); + let red = state.render_offscreen(); + assert_eq!( + state.status_left_runs.as_ref().expect("left shaped")[2].1, + Color::rgb(240, 10, 20) + ); + + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(10, 220, 40), + ..CellStyle::default() + }, + )], + ); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let green = state.render_offscreen(); + assert_ne!(red, green, "constant text must repaint after ThemeFacts"); + assert_eq!( + state.status_left_runs.as_ref().expect("left reshaped")[2].1, + Color::rgb(10, 220, 40) + ); + let (_, min_y, _, max_y) = + frame_diff_bounds(&red, &green, width).expect("recolor changes pixels"); + assert!( + min_y >= text_area_bottom(height, state.fm).floor() as u32 && max_y <= height, + "the recolor stays inside the status band" + ); + } + + #[test] + fn built_in_only_overwide_readout_clips_left_and_keeps_its_right_tail_pinned() { + let (narrow_width, wide_width, height) = (96, 500, 260); + let Some(mut narrow) = headless_or_skip(narrow_width, height, "text") else { + return; + }; + let Some(mut wide) = headless_or_skip(wide_width, height, "text") else { + return; + }; + for state in [&mut narrow, &mut wide] { + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + } + + let narrow_frame = narrow.render_offscreen(); + let wide_frame = wide.render_offscreen(); + assert!( + narrow.statusline_segments.is_none() && wide.statusline_segments.is_none(), + "fixture must exercise the built-in-only legacy surface" + ); + let narrow_status_width = narrow + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let wide_status_width = wide + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + assert!( + (narrow_status_width - wide_status_width).abs() < 0.01, + "surface width must not reshape the no-wrap readout" + ); + assert!( + narrow_width as f32 - STATUS_TEXT_PAD - narrow_status_width < 0.0, + "fixture must force the built-in readout past the left edge" + ); + assert!( + wide_width as f32 - STATUS_TEXT_PAD - wide_status_width > 0.0, + "comparison surface must fit the complete built-in readout" + ); + + let band_top = text_area_bottom(height, narrow.fm).floor() as u32; + let pinned_tail_width = 80; + for y in band_top..height { + for offset in 0..pinned_tail_width { + assert_eq!( + px_at(&narrow_frame, narrow_width, narrow_width - 1 - offset, y), + px_at(&wide_frame, wide_width, wide_width - 1 - offset, y), + "built-in readout tail moved at right-edge offset {offset}, y={y}" + ); + } + } + } + + #[test] + fn overwide_status_runs_never_wrap_and_keep_the_suffix_pinned() { + let (width, height) = (800, 300); + for size in [600, 7200] { + let Some(mut state) = headless_or_skip(width, height, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + state.apply_font_facts(None, Some(size)); + let baseline = state.render_offscreen(); + let suffix_width = state + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let suffix_left = (width as f32 - STATUS_TEXT_PAD - suffix_width) + .max(0.0) + .ceil() as u32; + + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment( + "L".repeat(MAX_STATUSLINE_SEGMENT_BYTES), + "ui.modeline", + )], + vec![statusline_segment( + "R".repeat(MAX_STATUSLINE_SEGMENT_BYTES), + "ui.modeline", + )], + ); + let overwide = state.render_offscreen(); + let full_width = state + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let actual_origin = width as f32 - STATUS_TEXT_PAD - full_width; + assert!(actual_origin < 0.0, "fixture must cross the left edge"); + assert_eq!(state.status_buffer.wrap(), Wrap::None); + assert_eq!(state.status_left_buffer.wrap(), Wrap::None); + assert_eq!(state.status_buffer.layout_runs().count(), 1); + assert_eq!(state.status_left_buffer.layout_runs().count(), 1); + + let band_top = text_area_bottom(height, state.fm).floor() as u32; + for y in band_top..height { + for x in suffix_left..width { + assert_eq!( + px_at(&overwide, width, x, y), + px_at(&baseline, width, x, y), + "protected suffix pixel moved at size {size}, ({x},{y})" + ); + } + } + let (_, min_y, _, max_y) = + frame_diff_bounds(&baseline, &overwide, width).expect("custom text paints"); + assert!(min_y >= band_top && max_y <= height); + } + } #[test] fn headless_theme_facts_empty_table_renders_identically() { @@ -10426,9 +11049,8 @@ mod tests { } } - /// Acceptance 13 — the two string-equality status caches drop on - /// a font change, so an unchanged composed status re-shapes with - /// the new attrs on the next frame. + /// Acceptance 13 — the rich-run status caches drop on a font + /// change, so unchanged content re-shapes with new attrs. #[test] #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums fn font_change_invalidates_the_status_shaping_caches() { @@ -10436,27 +11058,25 @@ mod tests { return; }; let _ = state.render_offscreen(); - let composed_before = state.status_text.clone(); - assert!( - !composed_before.is_empty(), - "precondition: a frame composed the status readout" - ); + let right_before = state.status_runs.clone().expect("right cache shaped"); + let left_before = state + .status_left_runs + .clone() + .expect("left cache shaped, including empty content"); + assert!(!right_before.is_empty(), "the readout is always present"); + state.apply_attach_message(font_facts(None, Some(3200))); - assert_eq!( - state.status_text, "\0", - "the sentinel must defeat the string-equality gate" - ); - assert_eq!(state.status_left_text, "\0"); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let _ = state.render_offscreen(); assert_eq!( state.status_buffer.metrics().font_size, state.fm.status_font_size(), "the re-shaped band must carry the derived metrics" ); - assert_eq!( - state.status_text, composed_before, - "same composed text, re-shaped anyway" - ); + assert_eq!(state.status_runs.as_ref(), Some(&right_before)); + assert_eq!(state.status_left_runs.as_ref(), Some(&left_before)); } /// Acceptance 14 — a size that shrinks the visible line count diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 1585558..70377f1 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -50,9 +50,11 @@ pub use message::{ CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, - LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, - NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, - SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace, - is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities, + LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, + PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, + is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 6ff187e..fe88144 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -104,6 +104,32 @@ pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\ pub fn is_builtin_pair_char(c: char) -> bool { BUILTIN_PAIR_CHARS.contains(&c) } +/// Maximum number of live statusline providers and wire segments. +pub const MAX_STATUSLINE_PROVIDERS: usize = 64; + +/// Maximum UTF-8 byte length of a statusline provider's display name. +pub const MAX_STATUSLINE_PROVIDER_NAME_BYTES: usize = 256; + +/// Maximum UTF-8 byte length of a statusline segment face name. +pub const MAX_STATUSLINE_FACE_BYTES: usize = 256; + +/// Maximum UTF-8 byte length of one statusline segment's text. +pub const MAX_STATUSLINE_SEGMENT_BYTES: usize = 1024; + +/// Maximum aggregate UTF-8 text bytes in one statusline payload. +pub const MAX_STATUSLINE_TOTAL_TEXT_BYTES: usize = 64 * 1024; + +/// True when `name` belongs to the reserved UI-face namespace. +#[must_use] +pub fn is_ui_face_name(name: &str) -> bool { + name == "ui" || name.starts_with("ui.") +} + +/// True when `name` is the modeline face or one of its children. +#[must_use] +pub fn is_modeline_face_name(name: &str) -> bool { + name == "ui.modeline" || name.starts_with("ui.modeline.") +} /// Modifier-key set. Bit-flag encoding for compact wire shape. /// @@ -1026,6 +1052,21 @@ pub enum InstanceMessage { /// closed (deserialized protocol input is untrusted). size_centi_px: Option, }, + /// Statusline segments (Q#SL7, protocol v18). Custom provider output + /// for the semantic frontend's current buffer. This is a complete + /// replacement: empty vectors authoritatively mean no custom segments. + /// Daemon-gated `>= 18`. + /// + /// Appended after [`Self::FontFacts`], the final v17 variant, so no + /// existing postcard discriminant moves. + StatuslineSegments { + /// Buffer whose modeline the segments describe. + buffer_id: crate::BufferId, + /// Left-side custom segments in display order. + left: Vec, + /// Right-side custom segments in display order. + right: Vec, + }, } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1041,6 +1082,19 @@ pub struct ThemeFace { pub style: crate::cell::Style, } +/// One daemon-produced custom modeline segment. +/// +/// `text` has already been sanitized to one line. `face` is +/// `ui.modeline` or one of its child names; a missing exact entry in +/// [`InstanceMessage::ThemeFacts`] means the base modeline text color. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StatuslineSegment { + /// Non-empty, single-line segment text. + pub text: String, + /// Static modeline face name selected at provider registration. + pub face: String, +} + /// Line-number gutter mode for a window (UX gutter arc). Shared across the /// wire, the daemon, and both frontends so the *number rule* — what value /// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports @@ -1411,7 +1465,13 @@ pub enum ResourceBody { /// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in /// font. Appended after `ThemeFacts` — the final v16 variant — /// same ordinal-discriminant reasoning as every additive bump. -pub const PROTOCOL_VERSION: u32 = 17; +/// +/// Statusline segments (Q#SL7): bumped 17 → 18 for +/// [`InstanceMessage::StatuslineSegments`] — a new additive variant +/// carrying custom modeline provider output. Daemon-gated `< 18`; a +/// v17 peer keeps the built-in status band. Appended after `FontFacts` +/// so the final v17 discriminant remains stable. +pub const PROTOCOL_VERSION: u32 = 18; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1477,7 +1537,10 @@ pub const PROTOCOL_VERSION: u32 = 17; /// /// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts` /// is additive and daemon-gated per session, so the ladder resumes. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]; +/// +/// Q#SL7: extended to `[6, ..., 18]`. +/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/ansi.rs b/src/ansi.rs index 4455fe7..f57d58c 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -43,6 +43,80 @@ use crate::cell::{Color, Style, UnderlineStyle}; +/// Parser compatibility profile. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AnsiParserProfile { + /// Preserve the compile/REPL byte-stream contract. + #[default] + LineOriented, + /// Emit terminal operations for a stateful full-screen consumer. + FullScreen, +} + +#[allow(missing_docs)] +/// Erase direction for display and line operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EraseMode { + ToEnd, + ToStart, + All, + Saved, +} + +#[allow(missing_docs)] +/// DEC alternate-screen selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlternateScreenMode { + Mode47, + Mode1047, + Mode1049, +} + +#[allow(missing_docs)] +/// Terminal modes understood by the screen/input core. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalMode { + Insert, + Origin, + AutoWrap, + ApplicationCursor, + ApplicationKeypad, + CursorVisible, + BracketedPaste, + FocusReporting, + SynchronizedOutput, + MouseX10, + MouseButton, + MouseAny, + MouseSgr, +} + +#[allow(missing_docs)] +/// G0/G1 designation target and supported character set. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CharacterSetSlot { + G0, + G1, +} + +/// Character set designated into a DEC G0/G1 slot. +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CharacterSet { + Ascii, + DecSpecialGraphics, +} + +#[allow(missing_docs)] +/// Typed terminal query. Only these requests may generate PTY input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeviceRequest { + PrimaryAttributes, + SecondaryAttributes, + OperatingStatus, + CursorPosition, +} + // --------------------------------------------------------------------------- // Public output // --------------------------------------------------------------------------- @@ -53,6 +127,7 @@ use crate::cell::{Color, Style, UnderlineStyle}; /// at the moment the parser has enough context to commit to it /// (e.g., `Text` is emitted at every transition out of Ground, not /// per byte). +#[allow(missing_docs)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnsiEvent { /// Append literal text to the consumer's rope. Text never @@ -98,6 +173,61 @@ pub enum AnsiEvent { /// `CSI ? 1049 l`: alternate-screen exited. `Text` and /// `SetStyle` resume. AlternateScreenExit, + /// Full-screen-only terminal operations. + Bell, + LineFeed, + /// `ESC D`: advance one row, scrolling at the bottom margin. + Index, + /// `ESC E`: return to column zero and advance one row. + NextLine, + /// `ESC M`: move up one row, scrolling down at the top margin. + ReverseIndex, + HorizontalTab, + SetTabStop, + ClearTabStop, + ClearAllTabStops, + CursorUp(u32), + CursorDown(u32), + CursorForward(u32), + CursorBackward(u32), + CursorNextLine(u32), + CursorPreviousLine(u32), + CursorHorizontalAbsolute(u32), + CursorVerticalAbsolute(u32), + CursorPosition { + row: u32, + col: u32, + }, + EraseDisplay(EraseMode), + EraseLineMode(EraseMode), + EraseCharacters(u32), + InsertCharacters(u32), + DeleteCharacters(u32), + InsertLines(u32), + DeleteLines(u32), + ScrollUp(u32), + ScrollDown(u32), + SetScrollingRegion { + top: u32, + bottom: Option, + }, + SaveCursor, + RestoreCursor, + AlternateScreen { + mode: AlternateScreenMode, + enabled: bool, + }, + SetMode { + mode: TerminalMode, + enabled: bool, + }, + DesignateCharacterSet { + slot: CharacterSetSlot, + charset: CharacterSet, + }, + ShiftOut, + ShiftIn, + DeviceRequest(DeviceRequest), } /// Tunable knobs for [`AnsiParser`]. @@ -155,6 +285,7 @@ enum State { Ground, Escape, EscapeIntermediate, + EscapeIgnore, CsiEntry, CsiParam, CsiIntermediate, @@ -326,6 +457,7 @@ pub struct AnsiParser { osc_body: Vec, /// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F). escape_intermediates: Vec, + profile: AnsiParserProfile, config: AnsiParserConfig, } @@ -339,12 +471,24 @@ impl AnsiParser { /// Construct a parser with default configuration. #[must_use] pub fn new() -> Self { - Self::with_config(AnsiParserConfig::default()) + Self::with_profile(AnsiParserProfile::LineOriented) } - /// Construct a parser with custom configuration. + /// Construct a parser using the selected compatibility profile. + #[must_use] + pub fn with_profile(profile: AnsiParserProfile) -> Self { + Self::with_profile_and_config(profile, AnsiParserConfig::default()) + } + + /// Construct a line-oriented parser with custom configuration. #[must_use] pub fn with_config(config: AnsiParserConfig) -> Self { + Self::with_profile_and_config(AnsiParserProfile::LineOriented, config) + } + + /// Construct a parser with both an explicit profile and configuration. + #[must_use] + pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self { Self { state: State::Ground, current_style: Style::default(), @@ -356,6 +500,7 @@ impl AnsiParser { csi: CsiCollector::default(), osc_body: Vec::new(), escape_intermediates: Vec::new(), + profile, config, } } @@ -396,11 +541,11 @@ impl AnsiParser { // transition path (flush_text_run) does emit U+FFFD for // pending bytes because a non-text byte genuinely // interrupts the sequence; feed-boundary doesn't. - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } events } @@ -431,23 +576,22 @@ impl AnsiParser { pub fn finish(&mut self) -> Vec { let mut events = Vec::new(); self.flush_pending_utf8_as_replacement(); - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } - // Balancing state events, in unwind order. `reset` alone - // deliberately preserves alt-screen suppression (a - // mid-stream reset must not unhide alt-screen contents); a - // stream END does end it, observably. - if self.alt_screen_active { - self.alt_screen_active = false; - events.push(AnsiEvent::AlternateScreenExit); - } - if self.emitted_style != Style::default() { - events.push(AnsiEvent::SetStyle(Style::default())); + if self.profile == AnsiParserProfile::LineOriented { + if self.alt_screen_active { + self.alt_screen_active = false; + events.push(AnsiEvent::AlternateScreenExit); + } + if self.emitted_style != Style::default() { + events.push(AnsiEvent::SetStyle(Style::default())); + } } + self.alt_screen_active = false; self.current_style = Style::default(); self.emitted_style = Style::default(); self.reset(); @@ -472,28 +616,53 @@ impl AnsiParser { return; } - // Per-state byte cap. The counter increments for every byte - // consumed in any non-Ground state, and is reset to zero at - // every transition into a fresh sequence (ESC-anywhere) or - // back to Ground (normal dispatch / force-recover). At the - // limit, the parser drops the in-flight sequence and - // returns to Ground; the *current* byte is dropped on the - // floor, but subsequent bytes are processed normally as - // ordinary text. Spec §sec:ansi-scope: "drops back to ground - // state at the next ESC or after a bounded number of bytes - // (1 KiB), whichever comes first." - if self.state != State::Ground { + // Bound retained control-string payload without ever exposing its + // overflow as printable text. Once capped, remain in a zero-storage + // ignore state until BEL/ST or a fresh ESC sequence provides a safe + // recovery boundary. + if self.state != State::Ground + && !matches!( + self.state, + State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore + ) + { self.ignore_byte_count = self.ignore_byte_count.saturating_add(1); if self.ignore_byte_count > self.config.unknown_sequence_byte_limit { - self.recover_to_ground(); + match self.state { + State::OscString | State::OscEscPending => { + self.osc_body.clear(); + self.state = State::OscIgnore; + self.ignore_byte_count = 0; + } + State::DcsEntry + | State::DcsParam + | State::DcsIntermediate + | State::DcsPassthrough + | State::SosPmApcString => { + self.state = State::DcsIgnore; + self.ignore_byte_count = 0; + } + State::Escape | State::EscapeIntermediate => { + self.escape_intermediates.clear(); + self.state = State::EscapeIgnore; + self.ignore_byte_count = 0; + } + State::CsiEntry | State::CsiParam | State::CsiIntermediate => { + self.csi.reset(); + self.state = State::CsiIgnore; + self.ignore_byte_count = 0; + } + _ => self.recover_to_ground(), + } return; } } match self.state { State::Ground => self.feed_ground(b, events), - State::Escape => self.feed_escape(b), - State::EscapeIntermediate => self.feed_escape_intermediate(b), + State::Escape => self.feed_escape(b, events), + State::EscapeIntermediate => self.feed_escape_intermediate(b, events), + State::EscapeIgnore => self.feed_escape_ignore(b), State::CsiEntry => self.feed_csi_entry(b, events), State::CsiParam => self.feed_csi_param(b, events), State::CsiIntermediate => self.feed_csi_intermediate(b, events), @@ -530,13 +699,13 @@ impl AnsiParser { return; } let run = std::mem::take(&mut self.text_run); - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } fn emit_set_style(&mut self, events: &mut Vec) { - if self.alt_screen_active { + if self.suppress_visible() { return; } self.emitted_style = self.current_style; @@ -548,11 +717,15 @@ impl AnsiParser { /// paste / `SetTitle`). The alt-screen markers themselves /// bypass this. fn push_visible(&self, ev: AnsiEvent, events: &mut Vec) { - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(ev); } } + fn suppress_visible(&self) -> bool { + self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active + } + /// Begin a fresh escape sequence (called from ESC-anywhere). /// Resets the byte budget and all in-flight sequence state. fn start_new_sequence(&mut self) { @@ -579,44 +752,53 @@ impl AnsiParser { // ----------------------------------------------------------------------- fn feed_ground(&mut self, b: u8, events: &mut Vec) { - match b { - // CR: flush text, emit CarriageReturn. - 0x0D => { - self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::CarriageReturn); + if self.profile == AnsiParserProfile::LineOriented { + match b { + 0x0D => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::CarriageReturn, events); } + 0x08 => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::Backspace, events); + } + 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { + self.push_text_byte(b); + } + 0x00..=0x1F | 0x7F => {} + } + return; + } + match b { + 0x07 => { + self.flush_text_run(events); + events.push(AnsiEvent::Bell); } - // BS: flush text, emit Backspace. 0x08 => { self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::Backspace); - } + events.push(AnsiEvent::Backspace); } - // BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF - // (0x0A): pass through to text alongside printable - // ASCII (0x20..=0x7E). The REPL view treats LF as a - // line break in the rope; HT as a literal tab. Other - // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL - // (0x7F) are dropped silently. - // - // 0x80..=0xFF: UTF-8 lead or continuation byte. Goes - // through `push_text_byte`'s stateful decoder so - // multi-byte sequences across feeds are buffered until - // complete. - // - // All text bytes route through `push_text_byte` (not - // just non-ASCII): an ASCII byte arriving while a - // partial UTF-8 sequence is pending invalidates that - // sequence (the partial prefix's expected continuation - // didn't arrive), and `push_text_byte` is the only - // place that knows to flush the partial as `U+FFFD`. - // The fast path inside `push_text_byte` keeps the - // pure-ASCII case allocation-free. - 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { - self.push_text_byte(b); + 0x09 => { + self.flush_text_run(events); + events.push(AnsiEvent::HorizontalTab); } + 0x0A..=0x0C => { + self.flush_text_run(events); + events.push(AnsiEvent::LineFeed); + } + 0x0D => { + self.flush_text_run(events); + events.push(AnsiEvent::CarriageReturn); + } + 0x0E => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftOut); + } + 0x0F => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftIn); + } + 0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b), 0x00..=0x1F | 0x7F => {} } } @@ -726,7 +908,7 @@ impl AnsiParser { // Escape // ----------------------------------------------------------------------- - fn feed_escape(&mut self, b: u8) { + fn feed_escape(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => { self.escape_intermediates.push(b); @@ -740,42 +922,66 @@ impl AnsiParser { self.osc_body.clear(); self.state = State::OscString; } - // DCS / SOS / PM / APC introducers --- parse and discard. - b'P' => { - self.state = State::DcsEntry; - } - b'X' | b'^' | b'_' => { - self.state = State::SosPmApcString; - } - // ESC \ in Escape state is a stray ST; final byte for - // a bare ESC sequence (0x30..=0x7E) lands here too. We - // don't dispatch any single-byte ESC commands in v0.1 - // (cursor save/restore `ESC 7`/`ESC 8` are deliberately - // unsupported per spec); both cases consume and return - // to Ground. - b'\\' | 0x30..=0x7E => { + b'P' => self.state = State::DcsEntry, + b'X' | b'^' | b'_' => self.state = State::SosPmApcString, + b'7' | b'8' | b'D' | b'E' | b'H' | b'M' | b'=' | b'>' + if self.profile == AnsiParserProfile::FullScreen => + { + let event = match b { + b'7' => AnsiEvent::SaveCursor, + b'8' => AnsiEvent::RestoreCursor, + b'D' => AnsiEvent::Index, + b'E' => AnsiEvent::NextLine, + b'H' => AnsiEvent::SetTabStop, + b'M' => AnsiEvent::ReverseIndex, + b'=' => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: true, + }, + _ => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: false, + }, + }; + events.push(event); self.recover_to_ground(); } - // C0 controls inside Escape: drop, stay in Escape. + b'\\' | 0x30..=0x7E => self.recover_to_ground(), _ => {} } } - fn feed_escape_intermediate(&mut self, b: u8) { + fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec) { match b { - 0x20..=0x2F => { - self.escape_intermediates.push(b); - } - // Final byte: drop the sequence (no ESC + intermediate - // dispatches in v0.1 --- charsets are deliberately - // unsupported per spec) and return to Ground. + 0x20..=0x2F => self.escape_intermediates.push(b), 0x30..=0x7E => { + if self.profile == AnsiParserProfile::FullScreen { + let slot = match self.escape_intermediates.as_slice() { + [b'('] => Some(CharacterSetSlot::G0), + [b')'] => Some(CharacterSetSlot::G1), + _ => None, + }; + let charset = match b { + b'0' => Some(CharacterSet::DecSpecialGraphics), + b'B' => Some(CharacterSet::Ascii), + _ => None, + }; + if let (Some(slot), Some(charset)) = (slot, charset) { + events.push(AnsiEvent::DesignateCharacterSet { slot, charset }); + } + } self.recover_to_ground(); } _ => {} } } + fn feed_escape_ignore(&mut self, b: u8) { + if matches!(b, 0x30..=0x7E) { + self.recover_to_ground(); + } + } + // ----------------------------------------------------------------------- // CSI // ----------------------------------------------------------------------- @@ -850,70 +1056,129 @@ impl AnsiParser { /// Dispatch a fully-collected CSI sequence. `final_byte` is the /// terminating byte (`0x40..=0x7E`). The collected parameters /// are taken from `self.csi`. + #[allow(clippy::too_many_lines)] fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec) { - let private_marker = self.csi.private_marker; + let private = self.csi.private_marker; + let intermediates = self.csi.intermediates.clone(); let params = self.csi.finalize(); - - match (private_marker, final_byte) { - // SGR. - (None, b'm') => self.dispatch_sgr(¶ms, events), - // Erase in line: `CSI [n] K`. n=0 (default) → - // EraseToEol; n=2 → EraseLine; n=1 (start to cursor) - // and others: parsed and ignored. - (None, b'K') => { - let n = params.first().map_or(0, |p| p.main); - match n { + if private.is_none() && final_byte == b'm' { + self.dispatch_sgr(¶ms, events); + self.csi.reset(); + return; + } + if self.profile == AnsiParserProfile::LineOriented { + match (private, final_byte) { + (None, b'K') => match param(¶ms, 0, 0) { 0 => self.push_visible(AnsiEvent::EraseToEol, events), 2 => self.push_visible(AnsiEvent::EraseLine, events), _ => {} - } - } - // Bracketed paste markers: `CSI 200 ~` / `CSI 201 ~`. - (None, b'~') => { - let n = params.first().map_or(0, |p| p.main); - match n { + }, + (None, b'~') => match param(¶ms, 0, 0) { 200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events), 201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events), _ => {} - } - } - // DEC private mode set / reset: `CSI ? h` / `l`. - // Of these, only ?1049 (alternate screen) produces an - // event; mouse modes (?1000, ?1006), bracketed-paste - // mode (?2004), and the long tail are parsed and - // discarded per spec §sec:ansi-scope. - (Some(b'?'), b'h' | b'l') => { - let set = final_byte == b'h'; - for p in ¶ms { - if p.main == 1049 { - if set && !self.alt_screen_active { - self.alt_screen_active = true; - events.push(AnsiEvent::AlternateScreenEnter); - } else if !set && self.alt_screen_active { - self.alt_screen_active = false; - events.push(AnsiEvent::AlternateScreenExit); - // SGR changes inside the alternate - // screen advanced `current_style` while - // their events were suppressed; the - // consumer still holds the pre-enter - // style. Resynchronize the effective - // style on exit (round-4 finding 2). - if self.current_style != self.emitted_style { - self.emit_set_style(events); + }, + (Some(b'?'), b'h' | b'l') => { + let set = final_byte == b'h'; + for p in ¶ms { + if p.main == 1049 { + if set && !self.alt_screen_active { + self.alt_screen_active = true; + events.push(AnsiEvent::AlternateScreenEnter); + } else if !set && self.alt_screen_active { + self.alt_screen_active = false; + events.push(AnsiEvent::AlternateScreenExit); + if self.current_style != self.emitted_style { + self.emit_set_style(events); + } } } } } + _ => {} } - // Cursor motions (A/B/C/D/E/F/G/H/J/f) and other CSI - // commands: parsed and discarded for M6.3. The M6.4 - // view layer handles intra-line motion (CR / BS) at - // its own level; cross-region motion via CSI is in the - // "parsed but ignored when it would cross region - // boundaries" bucket from §sec:ansi-scope. - _ => {} + self.csi.reset(); + return; } + let count = || param(¶ms, 0, 1).max(1); + let event = match (private, intermediates.as_slice(), final_byte) { + (None, [], b'A') => Some(AnsiEvent::CursorUp(count())), + (None, [], b'B') => Some(AnsiEvent::CursorDown(count())), + (None, [], b'C' | b'a') => Some(AnsiEvent::CursorForward(count())), + (None, [], b'D') => Some(AnsiEvent::CursorBackward(count())), + (None, [], b'E') => Some(AnsiEvent::CursorNextLine(count())), + (None, [], b'F') => Some(AnsiEvent::CursorPreviousLine(count())), + (None, [], b'G' | b'`') => Some(AnsiEvent::CursorHorizontalAbsolute( + param(¶ms, 0, 1).max(1), + )), + (None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute( + param(¶ms, 0, 1).max(1), + )), + (None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition { + row: param(¶ms, 0, 1).max(1), + col: param(¶ms, 1, 1).max(1), + }), + (None, [], b'J') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseDisplay), + (None, [], b'K') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseLineMode), + (None, [], b'X') => Some(AnsiEvent::EraseCharacters(count())), + (None, [], b'@') => Some(AnsiEvent::InsertCharacters(count())), + (None, [], b'P') => Some(AnsiEvent::DeleteCharacters(count())), + (None, [], b'L') => Some(AnsiEvent::InsertLines(count())), + (None, [], b'M') => Some(AnsiEvent::DeleteLines(count())), + (None, [], b'S') => Some(AnsiEvent::ScrollUp(count())), + (None, [], b'T') => Some(AnsiEvent::ScrollDown(count())), + (None, [], b'r') => Some(AnsiEvent::SetScrollingRegion { + top: param(¶ms, 0, 1).max(1), + bottom: params.get(1).map(|p| p.main).filter(|&n| n != 0), + }), + (None, [], b's') => Some(AnsiEvent::SaveCursor), + (None, [], b'u') => Some(AnsiEvent::RestoreCursor), + (None, [], b'g') => match param(¶ms, 0, 0) { + 0 => Some(AnsiEvent::ClearTabStop), + 3 => Some(AnsiEvent::ClearAllTabStops), + _ => None, + }, + (None, [], b'~') => match param(¶ms, 0, 0) { + 200 => Some(AnsiEvent::BracketedPasteBegin), + 201 => Some(AnsiEvent::BracketedPasteEnd), + _ => None, + }, + (None, [], b'h' | b'l') => { + let enabled = final_byte == b'h'; + for p in ¶ms { + if p.main == 4 { + events.push(AnsiEvent::SetMode { + mode: TerminalMode::Insert, + enabled, + }); + } + } + None + } + (Some(b'?'), [], b'h' | b'l') => { + let enabled = final_byte == b'h'; + for p in ¶ms { + if let Some(ev) = private_mode_event(p.main, enabled) { + events.push(ev); + } + } + None + } + (None, [], b'c') => Some(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)), + (Some(b'>'), [], b'c') => { + Some(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes)) + } + (None, [], b'n') => match param(¶ms, 0, 0) { + 5 => Some(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)), + 6 => Some(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)), + _ => None, + }, + _ => None, + }; + if let Some(event) = event { + events.push(event); + } self.csi.reset(); } @@ -959,9 +1224,13 @@ impl AnsiParser { _ => UnderlineStyle::Single, }; } - // 5/6 (blink, rapid blink): mapped to bold per spec - // §sec:ansi-scope ("blink-as-bold"). - 5 | 6 => self.current_style.bold = true, + // Line-oriented compile/REPL consumers historically render + // blink as bold. Full-screen preserves the shared Style + // contract: blink is unsupported and leaves it unchanged. + 5 | 6 if self.profile == AnsiParserProfile::LineOriented => { + self.current_style.bold = true; + } + 5 | 6 => {} 7 => self.current_style.reverse = true, // 8 (concealed/invisible): no-op. Out of scope. 8 => {} @@ -975,9 +1244,8 @@ impl AnsiParser { 22 => self.current_style.bold = false, 23 => self.current_style.italic = false, 24 => self.current_style.underline = UnderlineStyle::None, - // 25 (blink off): no-op. Symmetry with 5/6 → bold: - // we do not unset bold here, since that would also - // unset bold acquired via SGR 1. + // 25 (blink off): unsupported. It must not unset bold + // acquired through SGR 1. 25 => {} 27 => self.current_style.reverse = false, 28 => {} @@ -1039,11 +1307,12 @@ impl AnsiParser { } // ESC: begin ST-terminator check (ESC \). 0x1B => self.state = State::OscEscPending, - // 0x20..=0x7F: body bytes. The per-state byte cap - // (enforced at the top of `feed_byte`) bounds how many - // bytes we'll accept before force-recovering. - 0x20..=0x7F => self.osc_body.push(b), - // Other C0/C1 controls: drop silently, stay in OSC. + // OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII, + // DEL (compatibility), and all high bytes; lossy UTF-8 decoding at + // dispatch replaces malformed sequences. The per-state cap bounds + // retained storage. + 0x20..=0xFF => self.osc_body.push(b), + // Other C0 controls: drop silently, stay in OSC. _ => {} } } @@ -1083,7 +1352,7 @@ impl AnsiParser { let num: Option = std::str::from_utf8(num_part) .ok() .and_then(|s| s.parse().ok()); - if matches!(num, Some(133)) && !self.alt_screen_active { + if matches!(num, Some(133)) && !self.suppress_visible() { match text_part.first().copied() { Some(b'A') => events.push(AnsiEvent::PromptStart), Some(b'B') => events.push(AnsiEvent::PromptEnd), @@ -1099,13 +1368,66 @@ impl AnsiParser { // above produce events. Other OSC numbers are parsed and // discarded per spec §sec:ansi-scope, with the critical // guarantee that state alignment is preserved. - if matches!(num, Some(0 | 2)) && !self.alt_screen_active { + if matches!(num, Some(0 | 2)) && !self.suppress_visible() { let title = String::from_utf8_lossy(text_part).into_owned(); events.push(AnsiEvent::SetTitle(title)); } } } +fn param(params: &CsiParams, index: usize, default: u32) -> u32 { + params + .get(index) + .map_or(default, |p| if p.main == 0 { default } else { p.main }) +} + +fn erase_mode(value: u32) -> Option { + match value { + 0 => Some(EraseMode::ToEnd), + 1 => Some(EraseMode::ToStart), + 2 => Some(EraseMode::All), + 3 => Some(EraseMode::Saved), + _ => None, + } +} + +fn private_mode_event(value: u32, enabled: bool) -> Option { + let mode = match value { + 47 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode47, + enabled, + }); + } + 1047 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1047, + enabled, + }); + } + 1049 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled, + }); + } + 1 => TerminalMode::ApplicationCursor, + 6 => TerminalMode::Origin, + 7 => TerminalMode::AutoWrap, + 25 => TerminalMode::CursorVisible, + 66 => TerminalMode::ApplicationKeypad, + 1000 => TerminalMode::MouseX10, + 1002 => TerminalMode::MouseButton, + 1003 => TerminalMode::MouseAny, + 1004 => TerminalMode::FocusReporting, + 1006 => TerminalMode::MouseSgr, + 2004 => TerminalMode::BracketedPaste, + 2026 => TerminalMode::SynchronizedOutput, + _ => return None, + }; + Some(AnsiEvent::SetMode { mode, enabled }) +} + /// Parse a CSI 38/48 extended-color suffix into a `Color` plus /// the number of *additional* params consumed (legacy form only; /// the modern subparam form keeps everything inside `p.sub` so @@ -1995,4 +2317,177 @@ mod tests { even though the internal style is already default" ); } + + #[test] + fn full_screen_emits_typed_operation_set_across_every_split() { + let bytes = b"\x07\t\n\x1bD\x1bE\x1bM\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\ + \x1b[2E\x1b[2F\x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\ + \x1b[3@\x1b[4P\x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\ + \x1b[u\x1b[3g\x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\ + \x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n"; + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert!(expected.contains(&AnsiEvent::Bell)); + assert!(expected.contains(&AnsiEvent::Index)); + assert!(expected.contains(&AnsiEvent::NextLine)); + assert!(expected.contains(&AnsiEvent::ReverseIndex)); + assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 })); + assert!(expected.contains(&AnsiEvent::SetScrollingRegion { + top: 2, + bottom: Some(20) + })); + assert!(expected.contains(&AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition))); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn full_screen_finish_flushes_without_synthetic_balancing() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let events = parser.feed(b"\x1b[?1049h\x1b[31mred"); + assert!(events.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + })); + assert!( + events + .iter() + .any(|event| matches!(event, AnsiEvent::SetStyle(_))) + ); + assert!(parser.finish().is_empty()); + assert!(!parser.alt_screen_active); + assert!(parser.finish().is_empty()); + assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]); + } + + #[test] + fn full_screen_charset_designation_and_shift_are_typed() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + assert_eq!( + parser.feed(b"\x1b(0\x1b)B\x0e\x0f"), + vec![ + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G0, + charset: CharacterSet::DecSpecialGraphics, + }, + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G1, + charset: CharacterSet::Ascii, + }, + AnsiEvent::ShiftOut, + AnsiEvent::ShiftIn, + ] + ); + } + + #[test] + fn full_screen_capped_control_strings_recover_invisibly() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done"); + let visible: String = events + .iter() + .filter_map(|event| match event { + AnsiEvent::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert!(!visible.contains("secret")); + assert!(!visible.contains("AAAA")); + assert!(visible.ends_with("done")); + assert!(!visible.contains('\x1b')); + } + + #[test] + fn capped_csi_and_escape_intermediate_never_leak_payload() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + for input in [ + b"\x1b[12345678901234567890mOK".as_slice(), + b"\x1b[?999999999999999999hOK".as_slice(), + b"\x1b 0OK".as_slice(), + ] { + let mut parser = + AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let visible: String = parser + .feed(input) + .into_iter() + .filter_map(|event| { + if let AnsiEvent::Text(text) = event { + Some(text) + } else { + None + } + }) + .collect(); + assert_eq!(visible, "OK", "input {input:?}"); + } + } + + #[test] + fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + parser.feed(b"\x1b[1;3;31m"); + let before = parser.current_style; + parser.feed(b"\x1b[2;5;6;8;9;25;28;29m"); + assert_eq!(parser.current_style, before); + assert!(before.bold); + + let mut line = AnsiParser::new(); + line.feed(b"\x1b[5m"); + assert!( + line.current_style.bold, + "line-oriented blink-as-bold compatibility" + ); + } + + #[test] + fn unicode_osc_titles_survive_every_feed_split() { + let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes(); + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn malformed_utf8_osc_title_is_replaced_and_bounded() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 32, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]0;bad\xfftitle\x07"); + let title = events + .into_iter() + .find_map(|event| { + if let AnsiEvent::SetTitle(title) = event { + Some(title) + } else { + None + } + }) + .expect("title event"); + assert_eq!(title, "bad\u{fffd}title"); + assert!(title.len() <= config.unknown_sequence_byte_limit); + } } diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 8c11df8..5d50b19 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -414,6 +414,26 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("workspace/didChangeConfiguration", _) => { + // Record the pushed `settings` so a test can assert the + // daemon delivered configuration after `initialized` (the + // push-model config-delivery path push-only servers like the + // VS Code JSON server rely on). One JSON line per push. + if let Ok(sink) = std::env::var("PMACS_FAKE_LSP_CONFIG_SINK") { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&sink) + { + let settings = params + .get("settings") + .cloned() + .unwrap_or(serde_json::Value::Null); + let _ = writeln!(f, "{settings}"); + } + } + } ("textDocument/didOpen" | "textDocument/didChange", _) => { let uri = params .get("textDocument") diff --git a/src/buffer.rs b/src/buffer.rs index c90578a..c8fbb0a 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -160,6 +160,9 @@ pub struct Buffer { rope: Rope, name: String, is_modified: bool, + /// When set, every content mutation is rejected before touching the + /// rope, CRDT, history, revision, modified bit, marks, or views. + read_only: bool, /// Monotonic counter bumped by every successful forward edit, undo, /// and redo. Used by the editor to detect "did this command modify /// the buffer?" without reaching into the rope. LSP `did_change` @@ -243,6 +246,7 @@ impl Buffer { rope, name: name.into(), is_modified: false, + read_only: false, revision: 0, views: Vec::new(), next_view_id: 0, @@ -468,6 +472,32 @@ impl Buffer { self.is_modified = false; } + /// Whether content mutation is disabled for this buffer. + #[must_use] + pub fn is_read_only(&self) -> bool { + self.read_only + } + + /// Enable or disable the buffer-owned content-mutation guard. + /// + /// This is deliberately independent of edit intercepts: terminal identity + /// buffers use it to reject host-side edits, undo/redo, and remote CRDT + /// imports as well as ordinary interactive edits. + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + } + + fn ensure_writable(&self) -> Result<(), BufferError> { + if self.read_only { + Err(BufferError::ReadOnly { + id: self.id, + name: self.name.clone(), + }) + } else { + Ok(()) + } + } + /// Total length of the buffer in bytes. #[must_use] pub fn len(&self) -> Position { @@ -614,6 +644,7 @@ impl Buffer { /// `apply_edit_skip_intercepts` surfaces a typed error rather /// than silently corrupting state. pub fn begin_edit(&mut self) -> Result<(), BufferError> { + self.ensure_writable()?; if self.editing_in_progress { return Err(BufferError::ConcurrentEdit { id: self.id, @@ -661,6 +692,7 @@ impl Buffer { /// /// Threading: main thread only. pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result { + self.ensure_writable()?; if self.editing_in_progress { return Err(BufferError::ConcurrentEdit { id: self.id, @@ -732,6 +764,7 @@ impl Buffer { /// ops in a CRDT-redundant edge case). #[cfg(feature = "crdt")] pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result, BufferError> { + self.ensure_writable()?; if self.editing_in_progress { return Err(BufferError::ConcurrentEdit { id: self.id, @@ -942,6 +975,7 @@ impl Buffer { reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call" )] pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result { + self.ensure_writable()?; let mut views = std::mem::take(&mut self.views); let result = self.run_rope_edit_and_broadcast(&mut views, &op); self.views = views; @@ -1187,6 +1221,7 @@ impl Buffer { /// /// Threading: main thread only. pub fn undo(&mut self) -> Result { + self.ensure_writable()?; // T M10.4: in CRDT mode, route through loro's UndoManager via // the materialize-and-replace path (Day 1 morning audit // decision — path (a)). Inverse ops are produced as proper @@ -1294,6 +1329,7 @@ impl Buffer { /// /// Threading: main thread only. pub fn redo(&mut self) -> Result { + self.ensure_writable()?; // T M10.4: in CRDT mode, route through loro's UndoManager. #[cfg(feature = "crdt")] if self.crdt.is_some() { @@ -1675,6 +1711,15 @@ pub enum BufferError { /// The underlying rope rejected the operation. #[error("rope error: {0}")] Rope(#[from] RopeError), + /// A content mutation was attempted on a buffer whose owner marked it + /// read-only. The check runs before all rope, CRDT, and history changes. + #[error("buffer `{name}` (id {id:?}) is read-only")] + ReadOnly { + /// The protected buffer. + id: BufferId, + /// Buffer name for user-facing diagnostics. + name: String, + }, /// `undo` was called with an empty undo stack. #[error("nothing to undo")] NothingToUndo, @@ -1824,6 +1869,99 @@ mod tests { out } + dual_mode_test!( + read_only_rejects_direct_skip_history_mutations, + |make, make_bytes| { + let mut buf = make_bytes("*read-only*", b"abc"); + buf.apply_edit(EditOp::Insert { + pos: 3, + bytes: b"d", + }) + .expect("seed undo history"); + buf.undo().expect("seed redo history"); + buf.set_read_only(true); + + let before = ( + collect(&buf), + buf.revision(), + buf.is_modified(), + buf.undo.len(), + buf.redo.len(), + ); + assert!(matches!( + buf.begin_edit(), + Err(BufferError::ReadOnly { .. }) + )); + assert!(!buf.editing_in_progress()); + let attempts = [ + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"x", + }), + buf.apply_edit_skip_intercepts(EditOp::Replace { + range: Range::new(0, 1), + bytes: b"y", + }), + buf.undo(), + buf.redo(), + ]; + assert!( + attempts + .iter() + .all(|result| matches!(result, Err(BufferError::ReadOnly { .. }))) + ); + assert_eq!( + before, + ( + collect(&buf), + buf.revision(), + buf.is_modified(), + buf.undo.len(), + buf.redo.len(), + ) + ); + + // Keep the generated dual-mode factory used in both configurations. + drop(make("*unused*")); + } + ); + + #[cfg(feature = "crdt")] + #[test] + fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() { + let mut protected = Buffer::new(BufferId::next(), "*terminal*"); + protected.set_read_only(true); + protected + .upgrade_to_crdt(1) + .expect("immutable empty CRDT bootstrap remains valid"); + let before_snapshot = protected + .crdt_state() + .expect("CRDT attached") + .export_snapshot() + .expect("snapshot"); + + let donor = crate::crdt::CrdtState::new(2).expect("donor"); + let version = donor.version(); + donor.insert(0, "forged").expect("donor edit"); + let op = donor.export_updates_since(&version).expect("remote op"); + + assert!(matches!( + protected.apply_remote_crdt_op(&op), + Err(BufferError::ReadOnly { .. }) + )); + assert!(protected.is_empty()); + assert_eq!(protected.revision(), 0); + assert!(!protected.is_modified()); + assert_eq!( + protected + .crdt_state() + .expect("CRDT attached") + .export_snapshot() + .expect("snapshot"), + before_snapshot + ); + } + // A view that records every callback for assertions. #[derive(Default)] struct RecorderView { diff --git a/src/daemon.rs b/src/daemon.rs index 3cff08c..3587622 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -791,6 +791,14 @@ fn per_attach_thread( let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id }); } +/// Belt-and-braces write-loop gate for the additive protocol-v18 +/// statusline variant. The producer has its own callback/evaluation gate; +/// this filter independently prevents an unknown discriminant reaching an +/// older peer even if a message is injected into the frame vector. +fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMessage) -> bool { + protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. }) +} + /// T M10.8 — dispatcher loop. The single thread that owns the editor. /// /// All attached frontends' inputs arrive via the `dispatcher_rx` @@ -1144,6 +1152,12 @@ fn dispatcher_loop( let peer_knows_font_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 17); + // Q#SL7 — independently gate the v18 statusline variant + // even though the semantic producer also skips callbacks + // and message construction for older peers. + let negotiated_protocol_version = session_registry + .session_state(*fid) + .map_or(0, |s| s.negotiated_protocol_version); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) @@ -1186,6 +1200,9 @@ fn dispatcher_loop( if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) { continue; } + if !peer_accepts_statusline_message(negotiated_protocol_version, msg) { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // @@ -1267,6 +1284,10 @@ fn dispatcher_loop( last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); session_registry.unregister_session(*fid); + editor + .statusline_registry + .borrow_mut() + .detach_frontend(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid); } } @@ -1639,6 +1660,10 @@ fn handle_dispatcher_event( last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); session_registry.unregister_session(frontend_id); + editor + .statusline_registry + .borrow_mut() + .detach_frontend(frontend_id); { let mut core = editor.core.borrow_mut(); core.unregister_frontend_view(frontend_id); @@ -2555,6 +2580,24 @@ mod tests { assert_eq!(b, 3); } + #[test] + fn statusline_segments_write_gate_rejects_v17_independently() { + let segments = InstanceMessage::StatuslineSegments { + buffer_id: crate::buffer::BufferId::from_raw(1), + left: Vec::new(), + right: Vec::new(), + }; + assert!(!peer_accepts_statusline_message(17, &segments)); + assert!(peer_accepts_statusline_message(18, &segments)); + assert!(peer_accepts_statusline_message( + 17, + &InstanceMessage::FontFacts { + family: None, + size_centi_px: None, + } + )); + } + #[test] fn build_identity_includes_version_and_uptime() { let s = DaemonState::new(Some("research".into())); diff --git a/src/editor.rs b/src/editor.rs index 7bc447d..a8590ae 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -13,12 +13,15 @@ //! until the user quits. use std::cell::RefCell; +use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::rc::Rc; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyModifiers}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::CellCoord; @@ -61,6 +64,9 @@ pub struct EditorState { /// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor /// exit cannot leave zombies. pub process_supervisor: crate::lua_bindings::SharedProcessSupervisor, + /// Terminal session registry. Shared with future terminal Lua bindings; + /// snapshots are owned so no screen borrow crosses editor/Lua/render work. + pub terminal_manager: crate::terminal::session::SharedTerminalManager, /// LSP manager (T M4.5). Holds one [`crate::lsp::LspClient`] per /// language server; rides on top of [`Self::process_supervisor`] /// for spawn / I/O / restart. Constructed empty; user code @@ -101,6 +107,8 @@ pub struct EditorState { /// Snippet store (T M4.11). Co-owned with the snippet /// provider closure inside [`Self::completion_registry`]. pub snippets: crate::completion_framework::SharedSnippetRegistry, + /// Lua statusline providers shared by grid and semantic renderers. + pub statusline_registry: crate::statusline::SharedStatuslineRegistry, /// Last left-button down event, used to synthesize terminal double /// clicks from crossterm's plain Down/Up mouse event stream. mouse_click: Option, @@ -128,6 +136,11 @@ impl Drop for EditorState { /// stuck mid-handoff stays alive (bounded by its job), which is /// still a ~15x improvement over leaking every pool whole. fn drop(&mut self) { + { + let mut supervisor = self.process_supervisor.borrow_mut(); + self.terminal_manager.borrow_mut().shutdown(&mut supervisor); + supervisor.shutdown(); + } self.async_runtime.shutdown_workers(); } } @@ -167,6 +180,8 @@ impl EditorState { lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); + let statusline_registry = crate::lua_bindings::statusline_registry(lua_host.lua()) + .expect("statusline registry installed by editor bindings"); // The on-disk state dirs (minibuffer history + pmacs.state) are // deliberately NOT configured here — see `install_state_dirs`, // called by the real entry points (`run` / `run_daemon`) only. @@ -232,6 +247,7 @@ impl EditorState { // shutdown enforces no-zombie cleanup at editor exit. let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); + let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); // T M4.5 LSP manager. Wires onto the same supervisor so its // spawn/restart/I/O machinery is shared with `pmacs.process.*`. // The manager itself is reachable from Lua as `pmacs.lsp.*`. @@ -474,6 +490,7 @@ impl EditorState { async_runtime, syntax_registry, process_supervisor, + terminal_manager, lsp_manager, font_pref, mcp_manager, @@ -481,21 +498,40 @@ impl EditorState { project_indexer, completion_registry, snippets, + statusline_registry, mouse_click: None, } } - /// One pass of the process supervisor: drain pending I/O / exit - /// events and apply restart policies. Mirrors - /// [`Self::tick_async`]; the run loop calls both per iteration. + /// Transactionally open an internal Stage-1 terminal session. /// - /// Fires the `process.after-tick` hook (T M6.5) after the supervisor - /// tick releases its borrow. Lua subscribers typically own a - /// `{[process_id] = handle}` registry and drain events via - /// `pmacs.process.events_take(id)`; the REPL package - /// (`builtin/packages/repl/init.lua`) is the first such consumer. + /// No interactive Lua command is registered until a frontend can render + /// terminal snapshots. This Rust seam is used by headless acceptance and + /// future bindings. + pub fn open_terminal( + &mut self, + spec: crate::terminal::TerminalSpec, + ) -> Result { + let mut manager = self.terminal_manager.borrow_mut(); + let mut core = self.core.borrow_mut(); + let mut supervisor = self.process_supervisor.borrow_mut(); + manager.open(spec, &mut core, &mut supervisor) + } + + /// One pass of the process supervisor and terminal-owned event drain. + /// + /// Ordering is supervisor tick → terminal drain/prune → + /// `process.after-tick`. `TerminalManager` calls `take_events` only for its + /// own `ProcessId`s; existing Lua/LSP/MCP ownership remains unchanged. pub fn tick_processes(&mut self) { - self.process_supervisor.borrow_mut().tick(); + { + let mut supervisor = self.process_supervisor.borrow_mut(); + supervisor.tick(); + let mut manager = self.terminal_manager.borrow_mut(); + manager.tick(&mut supervisor); + let mut core = self.core.borrow_mut(); + manager.prune(&mut core, &mut supervisor); + } self.lua_host .run_hook("process.after-tick", mlua::MultiValue::new()); } @@ -2108,6 +2144,25 @@ pub fn paint_frame( return None; } let text_rows = term_size.rows - 1; + // Statusline callbacks may call arbitrary editor APIs. Evaluate the + // complete visible-window fan-out before the long mutable core borrow + // below, then paint only the transactionally validated owned results. + let frontend_id = state.core.borrow().active_frontend; + let statusline_evaluation = crate::statusline::evaluate_statusline( + state.lua_host.lua(), + &state.core, + &state.statusline_registry, + crate::statusline::StatuslineEvaluationTarget::Grid { frontend_id }, + ); + let statusline_by_window: HashMap = + match statusline_evaluation.outcome { + crate::statusline::StatuslineEvaluationOutcome::Ready(windows) => windows + .into_iter() + .map(|segments| (segments.context.window_id, segments)) + .collect(), + crate::statusline::StatuslineEvaluationOutcome::Invalidated { .. } + | crate::statusline::StatuslineEvaluationOutcome::NoMessage(_) => HashMap::new(), + }; // Themes Q#TH9: one theme clone per frame for the chrome faces — // the same single-lock discipline as `SyntaxHighlightView::render`. @@ -2226,6 +2281,7 @@ pub fn paint_frame( let guard = diag_store.lock().expect("diag store mutex poisoned"); diag_mode_line_summary(&guard, buf) }; + let custom = statusline_by_window.get(id); paint_mode_line( grid, &rect, @@ -2237,6 +2293,9 @@ pub fn paint_frame( &scroll, &diags, mode_line_style(&theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + &theme, ); } drop(reg); @@ -2549,9 +2608,131 @@ fn diag_mode_line_summary( } } +#[derive(Copy, Clone)] +struct ModeLineRun<'a> { + text: &'a str, + style: crate::cell::Style, +} + +struct ModeLineGrapheme { + glyph: crate::cell::Glyph, + width: u32, + style: crate::cell::Style, +} + +fn prepare_mode_line_runs(runs: &[ModeLineRun<'_>]) -> Vec { + let mut graphemes = Vec::new(); + for run in runs { + let sanitized = run.text.chars().any(char::is_control).then(|| { + run.text + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect::() + }); + let text = sanitized.as_deref().unwrap_or(run.text); + for grapheme in text.graphemes(true) { + let width = UnicodeWidthStr::width(grapheme) as u32; + if width == 0 { + continue; + } + let mut chars = grapheme.chars(); + let first = chars + .next() + .expect("unicode segmentation never yields an empty grapheme"); + let glyph = if chars.next().is_none() { + crate::cell::Glyph::Char(first) + } else { + crate::cell::Glyph::Cluster(grapheme.as_bytes().into()) + }; + graphemes.push(ModeLineGrapheme { + glyph, + width, + style: run.style, + }); + } + } + graphemes +} + +fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { + graphemes.iter().map(|grapheme| grapheme.width).sum() +} + +/// Paint complete graphemes at a logical signed origin. A grapheme that +/// straddles either clip edge is omitted wholesale, so a wide glyph can never +/// leave a dangling half-cell at a window or left/right collision boundary. +fn paint_mode_line_graphemes( + grid: &mut crate::cell::CellGrid<'_>, + rect: &crate::window::Rect, + row: u32, + origin: i64, + clip_start: u32, + clip_end: u32, + graphemes: &[ModeLineGrapheme], +) { + let mut logical_col = origin; + for grapheme in graphemes { + let next_col = logical_col + i64::from(grapheme.width); + if logical_col >= i64::from(clip_start) && next_col <= i64::from(clip_end) { + let local_col = + u32::try_from(logical_col).expect("non-negative clipped modeline column"); + let cell = grid.at(CellCoord::new(row, rect.origin.col + local_col)); + cell.glyph = grapheme.glyph.clone(); + cell.style = grapheme.style; + for continuation in 1..grapheme.width { + let cell = grid.at(CellCoord::new( + row, + rect.origin.col + local_col + continuation, + )); + cell.glyph = crate::cell::Glyph::Continuation; + cell.style = grapheme.style; + } + } + logical_col = next_col; + } +} + +fn statusline_segment_style( + theme: &crate::highlight::Theme, + face: &str, + base: crate::cell::Style, +) -> crate::cell::Style { + let Some(override_style) = theme.modeline_segment_face(face) else { + return base; + }; + let mut style = base; + if style.reverse { + style.bg = override_style.fg; + } else { + style.fg = override_style.fg; + } + style +} + +fn custom_mode_line_runs<'a>( + segments: &'a [crate::statusline::EvaluatedStatuslineSegment], + theme: &crate::highlight::Theme, + base: crate::cell::Style, +) -> Vec> { + let mut runs = Vec::with_capacity(segments.len().saturating_mul(2)); + for (index, segment) in segments.iter().enumerate() { + if index > 0 { + runs.push(ModeLineRun { + text: " ", + style: base, + }); + } + runs.push(ModeLineRun { + text: &segment.text, + style: statusline_segment_style(theme, &segment.face, base), + }); + } + runs +} + #[allow( clippy::too_many_arguments, - reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony" + reason = "the modeline packs built-in facts plus two already-evaluated custom sides" )] fn paint_mode_line( grid: &mut crate::cell::CellGrid<'_>, @@ -2563,10 +2744,10 @@ fn paint_mode_line( cursor_col: u32, scroll: &str, diags: &str, - // The resolved row style ([`mode_line_style`]) — this fn is a - // pure formatter, so the `ui.modeline` face resolution stays with - // the caller (themes arc Q#TH9). mode_style: crate::cell::Style, + custom_left: &[crate::statusline::EvaluatedStatuslineSegment], + custom_right: &[crate::statusline::EvaluatedStatuslineSegment], + theme: &crate::highlight::Theme, ) { if rect.size.rows == 0 || rect.size.cols == 0 { return; @@ -2574,46 +2755,78 @@ fn paint_mode_line( let row = rect.origin.row + rect.size.rows - 1; let marker = if modified { '*' } else { ' ' }; let active_marker = if is_active { '+' } else { '-' }; - let left = format!(" {active_marker}{marker} {name} "); - let right = if diags.is_empty() { + let protected_left = format!(" {active_marker}{marker} {name} "); + let protected_right = if diags.is_empty() { format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1) } else { format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1) }; - // Fill the row with the mode-line style. - for c in 0..rect.size.cols { - let cell = grid.at(CellCoord::new(row, rect.origin.col + c)); + // Fill exactly this window's row once with the base modeline surface. + for col in 0..rect.size.cols { + let cell = grid.at(CellCoord::new(row, rect.origin.col + col)); cell.glyph = crate::cell::Glyph::Char(' '); cell.style = mode_style; } - // Right-align the cursor / scroll readout. If the window is too - // narrow to fit both halves, drop the right side rather than - // overlap the buffer name. - let right_chars: Vec = right.chars().collect(); - let right_len = right_chars.len() as u32; - let right_start_col = if right_len < rect.size.cols { - Some(rect.size.cols - right_len) - } else { - None - }; - if let Some(start_col) = right_start_col { - for (i, ch) in right_chars.iter().enumerate() { - let col = rect.origin.col + start_col + i as u32; - grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(*ch); - } + let mut left_runs = Vec::with_capacity(custom_left.len().saturating_mul(2) + 2); + left_runs.push(ModeLineRun { + text: &protected_left, + style: mode_style, + }); + if !custom_left.is_empty() { + left_runs.push(ModeLineRun { + text: " ", + style: mode_style, + }); + left_runs.extend(custom_mode_line_runs(custom_left, theme, mode_style)); } + let left_graphemes = prepare_mode_line_runs(&left_runs); - // Paint the left side, stopping before the right side begins. - let stop_col = right_start_col.unwrap_or(rect.size.cols); - for (i, ch) in left.chars().enumerate() { - let i = i as u32; - if i >= stop_col { - break; + let protected_right_graphemes = prepare_mode_line_runs(&[ModeLineRun { + text: &protected_right, + style: mode_style, + }]); + let protected_right_width = mode_line_grapheme_width(&protected_right_graphemes); + + // Preserve the legacy strict boundary: a suffix as wide as the entire + // window is dropped wholesale. Custom text can never cause that drop when + // the protected suffix itself still satisfies the legacy fit test. + if protected_right_width < rect.size.cols { + let mut right_prefix_runs = custom_mode_line_runs(custom_right, theme, mode_style); + if !custom_right.is_empty() { + right_prefix_runs.push(ModeLineRun { + text: " ", + style: mode_style, + }); } - let col = rect.origin.col + i; - grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch); + let right_prefix_graphemes = prepare_mode_line_runs(&right_prefix_runs); + let right_prefix_width = mode_line_grapheme_width(&right_prefix_graphemes); + let suffix_start = rect.size.cols - protected_right_width; + let right_origin = i64::from(suffix_start) - i64::from(right_prefix_width); + let left_clip_end = u32::try_from(right_origin).unwrap_or(0); + + paint_mode_line_graphemes(grid, rect, row, 0, 0, left_clip_end, &left_graphemes); + paint_mode_line_graphemes( + grid, + rect, + row, + right_origin, + 0, + suffix_start, + &right_prefix_graphemes, + ); + paint_mode_line_graphemes( + grid, + rect, + row, + i64::from(suffix_start), + suffix_start, + rect.size.cols, + &protected_right_graphemes, + ); + } else { + paint_mode_line_graphemes(grid, rect, row, 0, 0, rect.size.cols, &left_graphemes); } } @@ -6921,6 +7134,403 @@ mod tests { } } + #[test] + fn statusline_no_visible_provider_preserves_ascii_modeline_cells() { + let s = fresh_with(b"hello"); + let (cells, stride, _) = render_to_grid(&s, 24, 80); + let actual = (0..80) + .map(|col| glyph_at(&cells, stride, 22, col)) + .collect::(); + let left = " + test "; + let right = " L1:C1 All "; + let expected = format!("{left}{}{right}", " ".repeat(80 - left.len() - right.len())); + assert_eq!(actual, expected); + } + + #[test] + fn statusline_real_frame_orders_runs_styles_separators_and_keeps_echo_independent() { + let s = fresh_with(b"hello"); + s.core.borrow_mut().status = "echo-only".to_owned(); + s.lua_host + .lua() + .load( + r#" + pmacs.theme.merge { + ["ui.modeline.red"] = { fg = 1 }, + ["ui.modeline.blue"] = { fg = 2 }, + } + _G.statusline_handles = { + pmacs.statusline.register { + name = "left-zero", side = "left", priority = 0, + face = "ui.modeline.blue", fn = function() return "L0" end, + }, + pmacs.statusline.register { + name = "left-high", side = "left", priority = 10, + face = "ui.modeline.red", fn = function() return "LH" end, + }, + pmacs.statusline.register { + name = "left-nil", side = "left", priority = 100, + fn = function() return nil end, + }, + pmacs.statusline.register { + name = "left-empty", side = "left", priority = 100, + fn = function() return "" end, + }, + pmacs.statusline.register { + name = "left-zero-late", side = "left", priority = 0, + face = "ui.modeline.blue", fn = function() return "L1" end, + }, + pmacs.statusline.register { + name = "right-zero", side = "right", priority = 0, + face = "ui.modeline.blue", fn = function() return "R0" end, + }, + pmacs.statusline.register { + name = "right-high", side = "right", priority = 10, + face = "ui.modeline.red", fn = function() return "RH" end, + }, + pmacs.statusline.register { + name = "right-zero-late", side = "right", priority = 0, + face = "ui.modeline.blue", fn = function() return "R1" end, + }, + } + "#, + ) + .exec() + .unwrap(); + + let (cells, stride, _) = render_to_grid(&s, 24, 100); + let mode = row_text(&cells, stride, 22, 100); + assert!( + mode.starts_with(" + test LH L0 L1"), + "wrong left composition: {mode:?}" + ); + assert!( + mode.ends_with("R0 R1 RH L1:C1 All"), + "wrong right composition: {mode:?}" + ); + assert!(!mode.contains("left-nil") && !mode.contains("left-empty")); + assert_eq!(row_text(&cells, stride, 23, 100), "echo-only"); + + let lh_col = mode.find("LH").unwrap() as u32; + let l0_col = mode.find("L0").unwrap() as u32; + let rh_col = mode.find("RH").unwrap() as u32; + let base = cells[(22 * stride) as usize].style; + for col in [lh_col, lh_col + 1, rh_col, rh_col + 1] { + let style = cells[(22 * stride + col) as usize].style; + assert!(style.reverse); + assert_eq!(style.bg, crate::cell::Color::Indexed(1)); + } + for col in [l0_col, l0_col + 1] { + let style = cells[(22 * stride + col) as usize].style; + assert!(style.reverse); + assert_eq!(style.bg, crate::cell::Color::Indexed(2)); + } + assert_eq!( + cells[(22 * stride + lh_col + 2) as usize].style, + base, + "custom/custom separator must retain ui.modeline" + ); + let protected_right_col = mode.find(" L1:C1 All").unwrap() as u32; + assert_eq!( + cells[(22 * stride + protected_right_col - 1) as usize].style, + base, + "custom/built-in separator must retain ui.modeline" + ); + } + + #[test] + fn statusline_real_frame_evaluates_distinct_split_contexts_and_focus() { + let s = fresh_with(b"left"); + s.lua_host + .lua() + .load( + r#" + _G.other_statusline_buffer = pmacs.buffer.create("other") + pmacs.window.split_vertical() + pmacs.window.switch_buffer(_G.other_statusline_buffer) + _G.statusline_seen = {} + _G.statusline_context_handle = pmacs.statusline.register { + name = "contexts", side = "left", + fn = function(ctx) + table.insert(_G.statusline_seen, { + frontend = ctx.frontend, + window = ctx.window, + buffer = tostring(ctx.buffer), + active = ctx.active, + }) + return ctx.active and "ACTIVE" or "PASSIVE" + end, + } + _G.statusline_split_clip_handle = pmacs.statusline.register { + name = "split-clipping", side = "right", + fn = function(ctx) + return string.rep(ctx.active and "X" or "Y", 20) + end, + } + "#, + ) + .exec() + .unwrap(); + + let (cells, stride, _) = render_to_grid(&s, 24, 120); + let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap(); + assert_eq!(seen.raw_len(), 2); + let first: mlua::Table = seen.raw_get(1).unwrap(); + let second: mlua::Table = seen.raw_get(2).unwrap(); + let first_window: u64 = first.get("window").unwrap(); + let second_window: u64 = second.get("window").unwrap(); + let first_buffer: String = first.get("buffer").unwrap(); + let second_buffer: String = second.get("buffer").unwrap(); + let first_frontend: u64 = first.get("frontend").unwrap(); + let second_frontend: u64 = second.get("frontend").unwrap(); + let first_active: bool = first.get("active").unwrap(); + let second_active: bool = second.get("active").unwrap(); + assert_ne!(first_window, second_window); + assert_ne!(first_buffer, second_buffer); + assert_eq!(first_frontend, FrontendId::LOCAL.0); + assert_eq!(second_frontend, FrontendId::LOCAL.0); + assert_ne!(first_active, second_active); + + let left_mode = (0..60) + .map(|col| glyph_at(&cells, stride, 22, col)) + .collect::(); + let right_mode = (60..120) + .map(|col| glyph_at(&cells, stride, 22, col)) + .collect::(); + assert!( + (left_mode.contains("ACTIVE") && right_mode.contains("PASSIVE")) + || (left_mode.contains("PASSIVE") && right_mode.contains("ACTIVE")) + ); + + s.lua_host + .lua() + .load("_G.statusline_seen = {}; pmacs.window.focus_next()") + .exec() + .unwrap(); + let _ = render_to_grid(&s, 24, 120); + let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap(); + assert_eq!(seen.raw_len(), 2); + let now_first: mlua::Table = seen.raw_get(1).unwrap(); + let now_second: mlua::Table = seen.raw_get(2).unwrap(); + let active_by_window = |table: &mlua::Table| { + ( + table.get::("window").unwrap(), + table.get::("active").unwrap(), + ) + }; + let flipped = [active_by_window(&now_first), active_by_window(&now_second)]; + assert!(flipped.contains(&(first_window, !first_active))); + assert!(flipped.contains(&(second_window, !second_active))); + let (narrow_cells, narrow_stride, _) = render_to_grid(&s, 24, 30); + let narrow_left = (0..15) + .map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col)) + .collect::(); + let narrow_right = (15..30) + .map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col)) + .collect::(); + assert!( + (narrow_left.contains('X') + && !narrow_left.contains('Y') + && narrow_right.contains('Y') + && !narrow_right.contains('X')) + || (narrow_left.contains('Y') + && !narrow_left.contains('X') + && narrow_right.contains('X') + && !narrow_right.contains('Y')), + "custom runs crossed a split boundary: left={narrow_left:?} right={narrow_right:?}" + ); + } + + #[test] + fn statusline_real_frame_discards_context_mutated_during_callback() { + let s = fresh_with(b"old"); + s.lua_host + .lua() + .load( + r#" + _G.statusline_switch_target = pmacs.buffer.create("switched") + _G.statusline_switch_once = true + _G.statusline_switch_handle = pmacs.statusline.register { + name = "context-mutator", side = "left", + fn = function() + if _G.statusline_switch_once then + _G.statusline_switch_once = false + pmacs.window.switch_buffer(_G.statusline_switch_target) + return "STALE" + end + return "FRESH" + end, + } + "#, + ) + .exec() + .unwrap(); + + let (cells, stride, _) = render_to_grid(&s, 24, 80); + let first = row_text(&cells, stride, 22, 80); + assert!( + first.contains("switched"), + "callback buffer switch did not land" + ); + assert!( + !first.contains("STALE"), + "invalidated old-context output reached the new buffer: {first:?}" + ); + + let (cells, stride, _) = render_to_grid(&s, 24, 80); + let second = row_text(&cells, stride, 22, 80); + assert!( + second.contains("FRESH"), + "next valid frame did not evaluate the surviving context: {second:?}" + ); + } + + #[test] + fn statusline_real_frame_paints_unicode_clusters_and_sanitizes_all_runs() { + let s = fresh_with(b"hello"); + { + let core = s.core.borrow(); + let registry = core.registry.clone(); + registry + .borrow_mut() + .get_mut(core.active_buffer_id()) + .unwrap() + .set_name("na\r\n\u{1b}me"); + } + s.lua_host + .lua() + .load( + r#" + _G.statusline_unicode_handle = pmacs.statusline.register { + name = "unicode", side = "left", + fn = function() return "\204\129界e\204\129\27Z" end, + } + "#, + ) + .exec() + .unwrap(); + + let (cells, stride, _) = render_to_grid(&s, 24, 80); + let row = &cells[(22 * stride) as usize..(23 * stride) as usize]; + let wide_col = row + .iter() + .position(|cell| cell.glyph == crate::cell::Glyph::Char('界')) + .expect("CJK grapheme should be present"); + assert_eq!(row[wide_col + 1].glyph, crate::cell::Glyph::Continuation); + assert_eq!( + row[wide_col + 2].glyph, + crate::cell::Glyph::Cluster("e\u{301}".as_bytes().into()) + ); + assert_eq!(row[wide_col + 3].glyph, crate::cell::Glyph::Char(' ')); + assert_eq!(row[wide_col + 4].glyph, crate::cell::Glyph::Char('Z')); + for cell in row { + match &cell.glyph { + crate::cell::Glyph::Char(ch) => assert!(!ch.is_control()), + crate::cell::Glyph::Cluster(bytes) => { + let text = std::str::from_utf8(bytes).unwrap(); + assert!(!text.chars().any(char::is_control)); + assert_ne!(text, "\u{301}", "standalone zero-width grapheme leaked"); + } + crate::cell::Glyph::Continuation => {} + } + } + let ascii_projection = row + .iter() + .map(|cell| match cell.glyph { + crate::cell::Glyph::Char(ch) => ch, + _ => '?', + }) + .collect::(); + assert!( + ascii_projection.contains("na me"), + "buffer-name controls were not replaced independently: {ascii_projection:?}" + ); + } + + #[test] + fn statusline_real_frame_clips_custom_edges_but_preserves_protected_suffix() { + let s = fresh_with(b"hello"); + s.lua_host + .lua() + .load( + r#" + _G.statusline_clip_handles = { + pmacs.statusline.register { + name = "left-high", side = "left", priority = 10, + fn = function() return "HIGH" end, + }, + pmacs.statusline.register { + name = "left-low", side = "left", priority = 0, + fn = function() return "界LOW" end, + }, + pmacs.statusline.register { + name = "right-low", side = "right", priority = 0, + fn = function() return "LOW" end, + }, + pmacs.statusline.register { + name = "right-high", side = "right", priority = 10, + fn = function() return "HIGH" end, + }, + } + "#, + ) + .exec() + .unwrap(); + + let (cells, stride, _) = render_to_grid(&s, 6, 17); + let mode = row_text(&cells, stride, 4, 17); + assert!( + mode.contains("HIGH"), + "high-priority right edge lost: {mode:?}" + ); + assert!( + !mode.contains("LOW"), + "low-priority right edge survived: {mode:?}" + ); + assert!( + mode.ends_with(" L1:C1 All"), + "protected suffix was not preserved in full: {mode:?}" + ); + assert_ne!( + cells[(4 * stride) as usize].glyph, + crate::cell::Glyph::Continuation, + "a clipped wide grapheme left a continuation at the window edge" + ); + + let left_only = fresh_with(b"hello"); + left_only + .lua_host + .lua() + .load( + r#" + _G.statusline_left_clip_handles = { + pmacs.statusline.register { + name = "left-high", side = "left", priority = 10, + fn = function() return "HIGH" end, + }, + pmacs.statusline.register { + name = "left-low", side = "left", priority = 0, + fn = function() return "界LOW" end, + }, + } + "#, + ) + .exec() + .unwrap(); + let (cells, stride, _) = render_to_grid(&left_only, 6, 26); + let mode = row_text(&cells, stride, 4, 26); + assert!(mode.starts_with(" + test HIGH")); + assert!(!mode.contains("LOW")); + assert!(mode.ends_with(" L1:C1 All")); + + let (cells, stride, _) = render_to_grid(&s, 6, 11); + let mode = row_text(&cells, stride, 4, 11); + assert!( + !mode.contains("L1:C1") && !mode.contains("HIGH") && !mode.contains("LOW"), + "a non-fitting protected suffix must drop the whole right group: {mode:?}" + ); + } + /// Give the active buffer a file path and return its `file://` /// URI, so diag-store entries can be keyed to it. fn set_active_buffer_path(s: &EditorState, path: &str) -> String { diff --git a/src/frontend.rs b/src/frontend.rs index 108798f..8fe6831 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -422,6 +422,10 @@ impl Frontend { // preference; terminal fonts belong to the terminal, so // the cell-grid TUI drops this silently too. | InstanceMessage::FontFacts { .. } + // Q#SL7 — custom statusline segments are semantic-only; + // the grid TUI paints provider output directly from the + // registry and silently drops an unexpected wire copy. + | InstanceMessage::StatuslineSegments { .. } | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path @@ -817,6 +821,28 @@ mod tests { .expect("the grid frontend must drop FontFacts silently"); } + #[test] + fn statusline_segments_drop_silently_on_the_grid_frontend() { + let mut fe = Frontend { + out: BufWriter::new(io::stdout()), + size: CellSize::new(24, 80), + raw_mode: false, + alt_screen: false, + bracketed_paste: false, + mouse: false, + keyboard_enhancement: false, + }; + fe.apply_message(&InstanceMessage::StatuslineSegments { + buffer_id: crate::buffer::BufferId::from_raw(7), + left: vec![pmacs_protocol::StatuslineSegment { + text: "project".into(), + face: "ui.modeline.project".into(), + }], + right: Vec::new(), + }) + .expect("the grid frontend must drop StatuslineSegments silently"); + } + #[test] fn emit_span_writes_cursor_move_then_chars() { let span = DiffSpan { diff --git a/src/highlight.rs b/src/highlight.rs index a74176a..487afb9 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -90,7 +90,7 @@ pub struct Theme { /// and the `ThemeFacts` producer's key filter. #[must_use] pub fn is_face_name(name: &str) -> bool { - name == "ui" || name.starts_with("ui.") + pmacs_protocol::is_ui_face_name(name) } impl Theme { @@ -226,6 +226,37 @@ impl Theme { } } + /// Resolve a custom modeline segment face relative to the already + /// resolved `ui.modeline` surface (statusline framing Q#SL6). + /// + /// Only a concrete foreground from the exact child or an intermediate + /// child is returned. The walk stops before `ui.modeline`: reaching the + /// base means the segment keeps the base modeline's effective text + /// color. An explicitly default foreground also stops inheritance and + /// returns to that base. Out-of-mask style components are discarded. + #[must_use] + pub fn modeline_segment_face(&self, name: &str) -> Option