Skip to content

refactor: extract terminal emulator behind a backend trait - #78

Open
aymanbagabas wants to merge 6 commits into
microsoft:mainfrom
aymanbagabas:refactor/terminal-emulator-backend-trait
Open

refactor: extract terminal emulator behind a backend trait#78
aymanbagabas wants to merge 6 commits into
microsoft:mainfrom
aymanbagabas:refactor/terminal-emulator-backend-trait

Conversation

@aymanbagabas

@aymanbagabas aymanbagabas commented Jul 29, 2026

Copy link
Copy Markdown
Member

Puts the terminal emulator behind an Emulator trait so additional backends (libghostty, xterm.js) can be added without touching the daemon, render, or assert layers, then makes the neutral grid vocabulary lossless enough for those backends to agree on.

The seam

  • EmuAlacrittyEmu, now the first impl Emulator; neutral grid types moved to terminal::cell
  • CommandTracker lifted out of the emulator into TermState — OSC 133/633/7 parsing is backend-neutral, so every backend gets identical shell integration by construction rather than reimplementing it
  • adds emulator_conformance_tests!, backend-agnostic tests any new backend has to pass

The cell model

The vocabulary flattened away detail the emulator had already parsed, and each loss surfaced downstream:

  • Color::Default was indistinguishable from ANSI 0, so expect --fg 0 matched a default-colored cell and reported it as black when the theme paints it light gray. Colors are Option<Color>; a default cell matches nothing.
  • A blank cell and the second column of a double-width character were both "", and text extraction substituted a space for each — inventing a column, so 你a extracted as 你 a and shifted every column after it. Blanks hold a real space, "" means continuation, extraction skips it.
  • Five underline styles collapsed into one bool and the underline color was dropped. Option<Underline> keeps both.
  • Palette slots 0-15 are themeable and 16-255 are fixed, but both landed in Color::Idx. Now Color::Named and Color::Idx, split by numeric range rather than by how the escape sequence spelled it, so a backend that only carries a palette index agrees with one that carries a name.

Attributes move into a bitflags set, which adds blink and reduces the per-cell style comparison in the render and monitor loops to one integer compare. ch becomes a CompactString, inline up to 24 bytes, so the grid stops heap-allocating per cell and can hold combining marks the backend was discarding.

Wire format

Colors are unchanged: named and indexed both serialize to their palette index. cells was reporting four of seven attributes, so dim, invisible, strike and blink are added along with underline_style and underline_color. blink is always false from alacritty, which parses SGR 5/6/25 and discards them, but ghostty and xterm.js both track it. A continuation cell now reports "" rather than a space; the binding types document it.

aymanbagabas and others added 3 commits July 29, 2026 14:56
The grid was produced directly by an alacritty-specific `Emu` struct, so
supporting another emulator meant editing every consumer. Split the seam:

- `terminal/cell.rs` holds the neutral grid vocabulary (`EmuCell`, `Color`,
  `rows_to_strings`) with no emulator dependency
- `terminal/emu.rs` defines the `Emulator` trait, the whole contract between
  the PTY and the grid
- `terminal/alacritty.rs` (moved from `emu.rs`) implements it

Command/exit/cwd tracking moves out of the emulator into `TermState`. It is
derived from the raw PTY byte stream and never touched the alacritty grid, so
keeping it outside the trait means every backend reports identical shell
integration by construction instead of reimplementing it. `integration.rs` is
unchanged.

Consumers already spoke `EmuCell`/`Color` rather than alacritty types, so this
is import churn for them. No behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Swapping the emulator behind the `Emulator` trait silently changes what
`expect`, `snapshot`, and the SVG renderer see, and the grid had no tests at
all. Add a macro-generated suite that every backend opts into with one line,
so a second backend is verified against the same contract as the first.

Covers grid shape, blank-vs-space cells, SGR attributes, palette/RGB/default
colors, wide-char spacers, cursor position and clamping, resize, scrollback
retention and ordering, alternate screen, erase, PTY write-back for DSR, and
escape sequences split across reads.

Each case is its own test, so a failure names the part of the contract that
broke. 17 cases pass against the alacritty backend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Review found several cases asserted far less than their names claimed, so a
materially broken backend could pass the suite that exists to gate backends:

- cursor clamping accepted any in-bounds value, including (0, 0); now pins the
  exact bottom-right cell
- DSR accepted any CSI prefix; now pins the exact cursor position report
- erase and blank cells checked rendered text only, so a backend that dropped
  colors or attributes passed; now compares whole cells against the default
- resize checked only the reported size, never that content survived
- full_rows compared lengths, not contents, and never pinned that history is
  followed by exactly the viewport, which `grid(full = true)` relies on

Also covers areas that were untested: split UTF-8 across reads (the reader
uses a fixed 8 KiB buffer, so real output splits codepoints), autowrap, tabs,
bare CR overwrite, backspace, erase-to-end-of-line, and the scrollback limit,
which no case exercised despite the daemon requesting 5000 rows.

Verified by mutation: a backend that reports the cursor at the origin, renders
blank cells as spaces, or drops scrollback now fails 8 cases; before it passed
all of them.

Tabs pin only cursor advance and landing column. Alacritty stores a literal
tab in the skipped cells and other emulators store blanks, so asserting the
rendered text there would fail a correct backend.

The macro no longer emits `use` statements into the caller's module; a second
backend whose test module imports Color or Emulator itself would have hit
E0252.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas
aymanbagabas marked this pull request as draft July 29, 2026 20:30
aymanbagabas and others added 3 commits July 29, 2026 16:53
The grid vocabulary flattened away detail that the emulator had already
parsed, and each loss surfaced somewhere downstream:

- `Color::Default` was indistinguishable from ANSI 0, so `expect --fg 0`
  matched a default-colored cell and claimed it was black when the theme
  paints it light gray. Colors are now `Option<Color>`, and a default cell
  matches no expected value at all.

- Palette slots 0-15 are themeable and 16-255 are fixed, but both landed
  in `Color::Idx`, leaving every consumer to re-derive the split. They are
  now `Color::Named` and `Color::Idx`, divided by numeric range rather than
  by how the escape sequence spelled it, so backends that carry only a
  palette index agree with backends that carry a name.

- Five distinct underline styles collapsed into one bool, and the
  underline's own color was dropped entirely. `Option<Underline>` keeps
  both.

- A blank cell and the second column of a double-width character were both
  the empty string, and text extraction substituted a space for each. That
  invented a column: `你a` extracted as `你 a`, shifting every subsequent
  column and breaking text matching. Blanks now hold a real space and the
  empty string means continuation, which extraction skips.

Attributes move into a `bitflags` set, which adds blink and turns the
per-cell style comparison in the render and monitor loops into a single
integer compare. `ch` becomes a `CompactString`, inline for anything up to
24 bytes, so the grid no longer heap-allocates per cell and can hold the
combining marks the backend was discarding.

The JSON wire format is unchanged: named and indexed colors both serialize
to their palette index and underline stays a boolean, so the JS and Python
bindings keep working. The one visible difference is that a continuation
cell now reports `""` instead of a space, which the binding types document.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
The `cells` response emitted four of the seven boolean attributes the
neutral model carries. `dim`, `invisible` and `strike` were parsed, stored
and then dropped at the wire, and `blink` had nowhere to go at all.

A client should be written against the vocabulary, not against whichever
backend happens to be compiled in, so all seven are reported now. `blink`
is always false from alacritty, which parses SGR 5/6/25 and discards them,
but ghostty (`Style.Flags.blink`) and xterm.js (`FgFlags.BLINK`) both track
it and will fill it in without any client change.

Underline style and color reach the wire for the same reason, as
`underline_style` and `underline_color` alongside the existing boolean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
The rationale on `NamedColor` claimed xterm.js carries only palette
indices. It does not: `CM_P16` and `CM_P256` are distinct color modes, so
xterm.js preserves `SGR 31` vs `SGR 38;5;1` exactly as alacritty does.

The reason to drop that distinction is ghostty, whose `Style.Color` has a
single `.palette` variant and cannot reproduce it, plus the fact that
nothing here consumes it: bold-to-bright keys off the index, not off how
the color was spelled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas
aymanbagabas marked this pull request as ready for review July 29, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant