Skip to content

fix: unify colour resolution behind a terminal profile - #94

Open
Ayman Bagabas (aymanbagabas) wants to merge 3 commits into
mainfrom
feat/terminal-config
Open

fix: unify colour resolution behind a terminal profile#94
Ayman Bagabas (aymanbagabas) wants to merge 3 commits into
mainfrom
feat/terminal-config

Conversation

@aymanbagabas

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

Copy link
Copy Markdown
Member

First of a three PR stack: #94#95#96.

A screenshot and a color assertion disagreed about what color a cell was, because each resolved palette indices through its own hardcoded table. render/svg.rs had a private Theme where red was #e88388; assert/color.rs had an ANSI16 table where red was #800000. Nothing kept them in sync and nothing noticed they had drifted.

The bug

Same program, same red text, on main:

$ shell-use submit 'printf "\033[31mERROR\033[0m: red text\n"'
$ shell-use screenshot --out shot.svg     # paints ERROR #e88388

$ shell-use expect text ERROR --fg '#800000'   # a color not in the image
$ echo $?
0                                              # ...passes

$ shell-use expect text ERROR --fg '#e88388'   # the color you can see
$ echo $?
1                                              # ...fails

Asserting the color the image actually paints failed, and asserting a color that appears nowhere passed. Either the picture was lying or the assertion was, and a test suite could not tell you which.

before — image paints #e88388 after — image paints #800000
before after

Both tables are deleted. One Colors type resolves every index once, so the renderer and the assertion cannot disagree by construction. --fg '#800000' now passes and matches the picture.

Profiles

The palette is no longer a private constant, so a session can be given one. --config picks the file and --profile the entry; discovery cascades ./shell-use.toml then ~/.shell-use/shell-use.toml.

[profiles.solarized]
scrollback = 10000

[profiles.solarized.colors]
background = "#002b36"
foreground = "#839496"
cursor     = "#d33682"
red        = "#dc322f"
green      = "#959900"
blue       = "#268bd2"
$ shell-use run --config shell-use.toml --profile solarized -- bash

solarized

A profile is exactly 19 values: the 16 ANSI colors plus foreground, background, and cursor. It is read only for the life of the session, so a program cannot rewrite the palette a test was pinned against. Anything outside 0-15 comes from a static xterm table, and scrollback defaults to 10000.

Notes for review

  • --fg <index> compares the cell's palette index, so it was never affected by this bug and still is not. Only #rrggbb resolves through the palette.
  • Snapshots record the slot ("fg": 1), not RGB, so existing baselines survive a palette change. There are tests pinning that.
  • Defaults are the VGA palette, foreground #c0c0c0 on background #000000, which is what the assertion side already used, so no assertion changes meaning.

A screenshot and a color assertion disagreed about what a cell was
painted. `render/svg.rs` carried a private sixteen-color table and
`assert/color.rs` carried a different one, so `expect --fg "#800000"`
passed on a cell the screenshot drew `#e88388`. Both tables are deleted
here and both callers resolve through one profile, which is what makes
them agree by construction rather than by coincidence.

The palette had to become configurable to fix it anyway: the two tables
could only be collapsed by choosing which one was right, and that choice
belongs to the user rather than to whichever module was read first. The
shipped default is the VGA/xterm palette that `TERM=xterm-256color`
already promises, which is what the assertion side used.

A profile is read from `shell-use.toml` and sets scrollback and colors.
Only the sixteen ANSI slots and the three defaults are configurable;
indices 16-255 are the xterm color cube and gray ramp, which are fixed by
the spec, so a config that could move them would let two sessions
disagree about what `--fg 196` means.

Profiles are named, and `--profile` selects one. The file is looked up
nearest first, project before user, so a repository can pin the terminal
its tests expect. Resolution happens in the CLI rather than the daemon:
the daemon is long-lived and shared, so it has no single working
directory to resolve a project-local config against, and a resolved
profile travels on `Request::Open` the same way timeouts already do.

Absent settings take the default, and the field is `#[serde(default)]`,
so a client that predates this keeps the behavior it had. Scrollback
moves from a hardcoded 5,000 to a configurable 10,000, matching
alacritty's own default.

Two things are deliberately errors rather than silent fallbacks: an
unknown profile name, which reports the ones that exist, and a config
file that does not parse, which would otherwise run the session with
settings nobody asked for. A *missing* file stays fine, since running
without one is normal.

Screenshots will look different: the default background is now black
rather than the previous dark blue-gray, and the palette is saturated
rather than muted. Both are recoverable in a profile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
main moved the request types out of the core crate: `crates/shell-use/src/protocol.rs`
was deleted, its wire types now live in `crates/shell-use-cli/src/protocol.rs`,
and the core speaks `Operation` / `OpenOptions` from the new `api.rs`.

The profile this branch adds followed that move. It is a field on `OpenOptions`
and `RunOptions` rather than a loose `spawn` argument, and the wire `Request::Open`
carries it as before, so the client still resolves the config file and the daemon
still never reads one. The bindings default it, which is the behaviour they have
today.

Colour resolution kept main's renames (`session`, `cell`, `matched`) and this
branch's palette argument, so assertions and screenshots still resolve through
the one profile.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a correctness gap where screenshots and expect --fg/--bg "#rrggbb" could disagree on the resolved RGB for the same ANSI palette index, by centralizing palette resolution in a session “profile” that is passed through open/run and used by both renderer and assertions.

Changes:

  • Introduces Profile/Colors types (TOML-backed) and resolves them in the CLI via --config / --profile (with search fallback).
  • Wires the resolved profile into session creation (scrollback) and into both SVG rendering and color assertion matching.
  • Adds lifecycle tests to ensure screenshots and assertions stay consistent, including with a custom profile.
Show a summary per file
File Description
SKILL.md Documents new open flags and adds a configuration section.
README.md Documents configuration/profile usage and color resolution behavior.
crates/shell-use/src/session.rs Stores a per-session Profile and uses it for emulator scrollback.
crates/shell-use/src/render/svg.rs Removes private theme table; renders using provided Colors.
crates/shell-use/src/profile.rs Adds profile/config parsing and unified color resolution logic.
crates/shell-use/src/lib.rs Exposes the new profile module.
crates/shell-use/src/engine.rs Passes profile through open/run; uses it for expect and screenshot rendering.
crates/shell-use/src/assert/color.rs Makes #rrggbb matching resolve via session Colors (unified with renderer).
crates/shell-use/src/api.rs Adds profile: Profile to OpenOptions / RunOptions.
crates/shell-use/Cargo.toml Adds toml dependency for profile/config parsing.
Cargo.toml Adds workspace toml dependency version.
Cargo.lock Locks new transitive dependencies for toml.
crates/shell-use-cli/tests/session_lifecycle.rs Adds end-to-end regression tests for screenshot/assertion color agreement and profile selection errors.
crates/shell-use-cli/src/protocol.rs Extends Request::Open to carry profile (serde default for compatibility).
crates/shell-use-cli/src/main.rs Resolves ProfileArgs and includes it in requests for open/run.
crates/shell-use-cli/src/cli.rs Adds ProfileArgs (--config, --profile) to open/run commands.
bindings/python/native/src/lib.rs Supplies default profile for native open/run operations.
bindings/js/native/lib.rs Supplies default profile for native open/run operations.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 17/18 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +198 to +217
pub fn rgb(&self, index: u8) -> Rgb {
match index {
0..=15 => self.ansi()[index as usize],
16..=231 => {
let i = index as u16 - 16;
let level = |c: u16| -> u8 {
if c == 0 {
0
} else {
(c * 40 + 55) as u8
}
};
Rgb::new(level((i / 36) % 6), level((i / 6) % 6), level(i % 6))
}
232..=255 => {
let v = ((index as u16 - 232) * 10 + 8) as u8;
Rgb::new(v, v, v)
}
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured this rather than guessing, and I am leaving it as is — happy to change it if you disagree.

Benchmark of the two shapes, release, 4000 lookups (an 80x24 screenshot resolves roughly that many):

per lookup per screenshot
ansi()[i] 5.9 ns 23.5 µs
16-arm match 1.3 ns 5.3 µs

So the array is ~4.5x slower per lookup but the whole saving is 18 µs per screenshot, against writing thousands of formatted SVG elements and a file. Adding #[inline] to ansi() does not close it (5.9 → 5.1 ns) because the index is runtime-variable, so the array still has to be materialised. The fix would be sixteen lines of match to buy that.

One correction to the premise, though: on the next PR in the stack (#95) Colors::rgb stops being the resolution path entirely — it is only reachable from tests there, and the live path becomes AlacrittyEmu::color, which reads colors.ansi() directly. So if this is worth doing it is worth doing there, not here.

Comment thread SKILL.md Outdated
The ellipsis in the `open` usage line means the flag before it can be
repeated. Adding `--config` and `--profile` after it moved that mark onto
`--profile`, which takes a single name.

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

Review pass on the stack — validated each comment before touching anything, and two of the six turned out not to need a change.

Fixed here: the --env ellipsis, which my own edit had shifted onto --profile.

Not changed, thread left open for you: the ansi() array in Colors::rgb. Benchmarked at 5.9 ns vs 1.3 ns per lookup, which is 18 µs per screenshot — sixteen lines of match to buy that. Details on the thread, and note rgb() stops being the live path in #95 anyway.

The other four are on #95 and #96, two of which were real bugs:

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.

2 participants