Skip to content

feat: answer OSC colour queries and track dynamic colours - #85

Closed
Ayman Bagabas (aymanbagabas) wants to merge 7 commits into
microsoft:feat/terminal-configfrom
aymanbagabas:feat/osc-colors
Closed

feat: answer OSC colour queries and track dynamic colours#85
Ayman Bagabas (aymanbagabas) wants to merge 7 commits into
microsoft:feat/terminal-configfrom
aymanbagabas:feat/osc-colors

Conversation

@aymanbagabas

@aymanbagabas Ayman Bagabas (aymanbagabas) commented Aug 4, 2026

Copy link
Copy Markdown
Member

Second of a three PR stack: #84#85#86. Stacked on #84; a cross-repo PR cannot be based on a fork branch, so GitHub shows #84's commit here too. Review only feat(terminal): answer OSC color queries onward.

A terminal is supposed to answer when a program asks what colors it is using. Ours never did, so every program that asked had to time out and guess.

Before: the query goes unanswered

A program asking for the background (OSC 11) and cursor (OSC 12) color:

before — no reply, twice after — answered
before after

That silence is not harmless. Programs use OSC 11 to decide whether they are on a light or dark background, so a timeout means an editor or a diff tool picks a theme by guessing, and an agent driving the terminal sees whichever colors the guess produced rather than the ones actually configured. It also costs a real second of wall clock per query while the program waits.

After: setting works too

OSC 10, OSC 11, OSC 12 set the foreground, background, and cursor; OSC 4 sets a palette entry. OSC 110, OSC 111, OSC 112, and OSC 104 reset them.

$ printf '\033]11;#1c2833\a'      # background
$ printf '\033]10;#eaeaea\a'      # foreground
$ printf '\033]4;1;#ff5f87\a'     # palette entry 1, red

$ printf '\033]111\a\033]110\a\033]104;1\a'   # put them all back
default after the three sets after the three resets
default set reset

Background #000000#1c2833#000000, red #800000#ff5f87#800000. The reset returns to the profile from #84, not to a second hardcoded default.

How it resolves

emulator.color(slot) = what a program set with OSC   (runtime, clearable)
                    ?? the session profile           (from #84, read only)
                    ?? the static xterm table        (indices 16-255)

A reset clears the runtime value only. It can never clear a configured one, so OSC 104 from a stray program cannot wipe the palette a test was pinned against.

Notes for review

  • The reply is built from alacritty's Event::ColorRequest, whose formatter already captured the query's prefix and terminator, so a BEL-terminated query gets a BEL-terminated answer and an ST-terminated one gets ST. There is no second parser over the PTY stream, and Term::colors() is readable directly once advance() returns.
  • Replies are queued into the same pending buffer as PtyWrite, so ordering with other terminal output is unchanged.
  • ColorSlot is an enum (Indexed(u8) | Foreground | Background | Cursor). Alacritty's internal 256/257/258 numbering stays inside alacritty.rs and does not leak into the profile type.
  • 22 tests, including conformance cases that run against every backend, so a future backend inherits them.

Programs ask the terminal what color it is before deciding whether to
draw for a light or a dark background. Nothing answered, so every one of
them blocked until it timed out and guessed.

The emulator answers now, through `take_pending_writes`, which already
exists for exactly this: the replies a terminal owes to device queries.
alacritty parses the sequence, tracks what a program set, and hands back
a formatter with the query's own prefix and terminator already captured,
so the only missing piece was the color itself. That comes from the
session profile, which the emulator is now constructed with.

The alternative was parsing the sequences off the PTY stream, the way
shell integration is tracked. That would have meant reimplementing color
parsing, the runtime table, reply formatting, and terminator tracking,
all of which the emulator already does — and getting the terminator
wrong, since it is only visible to whoever parsed the sequence. Reading
what the emulator already knows is both less code and more faithful.

Colors resolve in three layers: what a program set, else the session
profile, else the table the specification defines. A reset clears only
the first, so the profile is unreachable from the byte stream and there
is always something to restore. That is what the specification asks for,
describing a reset as restoring "the color specified by the corresponding
X resource".

`Emulator` gains `color(slot)`, which every backend answers from its own
state, plus a `palette()` snapshot of all 259 slots. The screenshot
renderer and `expect --fg/--bg` take that snapshot rather than the
emulator, so neither holds the session lock while it renders, and neither
knows which backend produced the colors.

Five conformance cases cover queries, terminator echo, set-then-reset,
unconfigured indices, and that a cell follows whatever its slot now
holds. They run against every backend, so a future one cannot answer
differently. An end-to-end test drives a real program through the whole
path: it reads the configured background, sets its own, resets, and gets
the configured one back.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Follow-up to the previous commit, which routed color resolution through a
`Palette` snapshot the session passed around. That put the fallback in
the wrong place: the emulator reported only what a program had set, and
every consumer had to know how to fill in the rest.

The emulator now takes the session profile at construction and answers
`color(slot)` for any slot, mapping its own table onto the profile when
nothing has overridden it. Consumers ask the emulator and get a color,
with no second layer to consult. `Palette` is gone.

The 256-color table above the sixteen configurable slots is a static
built at compile time rather than arithmetic run per lookup. It is the
same in every terminal, so computing it repeatedly only invited the two
implementations of it to drift.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
The profile addressed colors by index, using 256, 257, 258 for the
foreground, background, and cursor. That is alacritty's layout — it
stores the dynamic colors after the palette — and it had spread into the
profile, the emulator trait, and the conformance suite, none of which
have any reason to know it. A backend that numbered its own table
differently would have had to pretend otherwise.

Slots are a `ColorSlot` enum now: `Indexed(u8)`, `Foreground`,
`Background`, `Cursor`. The alacritty backend translates that to its own
indices inside the one match that reads its table, and resolves an
unset slot to the profile, falling back to the xterm table for an index
the profile does not name.

The tests for that resolution moved to the backend that performs it,
where they exercise the real path rather than a helper that mirrored it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A snapshot stores the palette slot a cell chose rather than the color
that slot resolves to, which is what lets a saved baseline outlive a
profile change: recoloring a terminal would otherwise invalidate every
snapshot in a suite at once.

That was already true and nothing checked it, so a change to how colors
are serialized could have quietly made snapshots profile-dependent. The
companion case pins the exception: a true-color cell names its own color,
so that one is recorded literally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
…fter

The color tests leaned almost entirely on `OSC 11`. That is the sequence
programs actually reach for, but it meant the foreground and cursor were
never set, never queried, and never reset, so wiring any of them to the
wrong slot would have gone unnoticed. Three conformance cases now set all
three to distinct colors and check that each reset frees only its own,
that each answers its own query, and that a bare `OSC 104` resets the
palette without touching them.

Nothing covered the path from an escape sequence to a rendered pixel
either. Both halves are pinned now: a screenshot paints the background a
program set and recolors a cell whose slot it moved, and an assertion
matches that same color while still comparing the index unchanged. Both
return to the profile after a reset. They read the same state, so this
is the earlier "a screenshot and an assertion agree" guarantee held at
every point in a session rather than only at the start.

The end-to-end test drives all three dynamic colors over a real PTY. It
needs a wide terminal: its report is one line, and `text` returns the
grid, so a narrower one wrapped the reply out of the assertion's reach.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas

Copy link
Copy Markdown
Member Author

cpendery (@cpendery) second of the stack, on top of #84 — please review that one first. Only feat(terminal): answer OSC color queries onward is new here; the base commit shows up because a cross-repo PR cannot be based on a fork branch.

The probe puts its own terminal in raw mode so it can read a reply that
arrives without a newline and must not be echoed. That needs `termios`,
which Windows CPython does not ship, so the test could only ever fail
there, and a fail-fast matrix let it cancel the other two platforms.

Nothing about the reply is platform specific. Its format is covered by
conformance cases that run against every backend, and the write that
carries it to the child is the same `pty.write` that every `type` and
`submit` already exercises on Windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Brings main's move of the request types out of the core crate down the
stack. Colour resolution keeps main's renames and this branch's emulator
argument, so assertions and screenshots still resolve through the emulator
rather than through a profile the caller holds separately.

`Session::profile` goes with it. The emulator takes the profile at
construction and is the only thing that resolves a colour, so the copy the
session kept had no readers left once the two call sites above moved over.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
@aymanbagabas
Ayman Bagabas (aymanbagabas) changed the base branch from main to feat/terminal-config August 5, 2026 17:49
@aymanbagabas

Copy link
Copy Markdown
Member Author

Superseded: reopening this from a branch in microsoft/shell-use rather than my fork, so the three can be linked as a real stack. GitHub does not allow a pull request from a fork to join a stack, and the head repository of an existing PR cannot be changed.

Same commits, same branch name; the replacement is linked below.

@aymanbagabas

Copy link
Copy Markdown
Member Author

Reopened as #95, based on #94 so the diff shows only this layer.

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