fix: unify colour resolution behind a terminal profile - #94
fix: unify colour resolution behind a terminal profile#94Ayman Bagabas (aymanbagabas) wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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/Colorstypes (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
|
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 Not changed, thread left open for you: the The other four are on #95 and #96, two of which were real bugs:
|
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>
28fa43d to
026eed9
Compare
There was a problem hiding this comment.
Review details
Suppressed comments (6)
README.md:165
- The CLI now accepts
--config/--profileforrunas well (perProfileArgsbeing flattened intoCommand::Run), but the command table still showsrun <program> [args...]without those flags. Please update the README command summary to include the newrunflags (or explicitly document that they’re open-only if that’s intended).
| Command | Description |
| ------------------------------------------------------------ | ------------------------------------------- |
| `open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V] [--config F] [--profile P] [--timeout-<class> MS]` | Spawn a shell session. |
| `run <program> [args...]` | Spawn a session running a program directly. |
SKILL.md:64
- The skill docs reflect
--config/--profileforopen, but not forrun, even thoughrunnow also supports these options via the flattenedProfileArgs. Please update this table row forrunto avoid users missing the new configuration knobs.
| `open [--shell S] [--cols N] [--rows N] [--cwd D] [--env K=V]... [--config F] [--profile P]` | Spawn a shell session (auto-starts the daemon). `--env` is repeatable. |
| `run <program> [args...] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a session running a program directly (no shell). |
crates/shell-use/src/profile.rs:69
- The parsing logic explicitly allows omitting the leading
#, but the error message says the color must be#rgbor#rrggbb. Consider adjusting the message to reflect the actual accepted formats (e.g.,rgb/rrggbb with optional leading #) to reduce confusion when users supply values like800000.
/// Parse `#rgb` or `#rrggbb`. The leading `#` is optional so a TOML value
/// that lost it to a stray quote still reads sensibly.
pub fn parse(s: &str) -> Result<Self, String> {
let hex = s.trim().trim_start_matches('#');
let read = |i: usize, n: usize| -> Result<u8, String> {
u8::from_str_radix(&hex[i..i + n], 16)
.map(|v| if n == 1 { v * 17 } else { v })
.map_err(|_| format!("invalid hex color {s:?}"))
};
match hex.len() {
3 => Ok(Rgb::new(read(0, 1)?, read(1, 1)?, read(2, 1)?)),
6 => Ok(Rgb::new(read(0, 2)?, read(2, 2)?, read(4, 2)?)),
_ => Err(format!("color must be #rgb or #rrggbb (got {s:?})")),
}
}
crates/shell-use/src/profile.rs:201
Colors::rgb()callsself.ansi()for indices 0..=15, which constructs a fresh[Rgb; 16]array every time. Sincergb()/resolve()are likely called per-cell during rendering and assertions, this adds avoidable work. Consider resolving 0..=15 via amatchonindex(returning the corresponding field), or storing the 16-color array once (e.g., as a field or via a small helper returning references) so repeated lookups don’t rebuild the array.
impl Colors {
/// The sixteen ANSI slots, in palette order.
pub fn ansi(&self) -> [Rgb; 16] {
[
self.black,
self.red,
self.green,
self.yellow,
self.blue,
self.magenta,
self.cyan,
self.white,
self.bright_black,
self.bright_red,
self.bright_green,
self.bright_yellow,
self.bright_blue,
self.bright_magenta,
self.bright_cyan,
self.bright_white,
]
}
/// The name a slot goes by in the config file.
pub fn slot_name(index: u8) -> Option<&'static str> {
Some(match NamedColor::from_index(index)? {
NamedColor::Black => "black",
NamedColor::Red => "red",
NamedColor::Green => "green",
NamedColor::Yellow => "yellow",
NamedColor::Blue => "blue",
NamedColor::Magenta => "magenta",
NamedColor::Cyan => "cyan",
NamedColor::White => "white",
NamedColor::BrightBlack => "bright_black",
NamedColor::BrightRed => "bright_red",
NamedColor::BrightGreen => "bright_green",
NamedColor::BrightYellow => "bright_yellow",
NamedColor::BrightBlue => "bright_blue",
NamedColor::BrightMagenta => "bright_magenta",
NamedColor::BrightCyan => "bright_cyan",
NamedColor::BrightWhite => "bright_white",
})
}
/// Resolve any 256-color index.
///
/// Slots 0-15 come from the profile. The color cube (16-231) and gray ramp
/// (232-255) are fixed by the xterm spec and identical under every profile.
pub fn rgb(&self, index: u8) -> Rgb {
match index {
0..=15 => self.ansi()[index as usize],
16..=231 => {
crates/shell-use/src/profile.rs:490
- This unit test hard-codes POSIX
/tmp/...paths, which will fail on Windows (and potentially in restricted environments). Usingstd::env::temp_dir()(or constructing a relativecwdlikePath::new(\"some-project\")) and building the pinned path viatemp_dir().join(\"pinned.toml\")would keep the test platform-independent while still validating search ordering and environment override behavior.
fn the_search_order_puts_the_project_first() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let old = std::env::var_os("SHELL_USE_CONFIG");
std::env::remove_var("SHELL_USE_CONFIG");
let cwd = Path::new("/tmp/some-project");
let result = std::panic::catch_unwind(|| {
let paths = search_paths(cwd);
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], cwd.join(CONFIG_FILE), "the project file is first");
assert!(
paths[1].ends_with(CONFIG_FILE) && paths[1] != paths[0],
"the user file is second: {:?}",
paths[1]
);
std::env::set_var("SHELL_USE_CONFIG", "/tmp/pinned.toml");
let pinned = search_paths(cwd);
assert_eq!(
pinned,
vec![PathBuf::from("/tmp/pinned.toml")],
"an explicit config replaces the search entirely"
);
});
crates/shell-use/src/api.rs:40
- Adding a new required field to a public struct (
OpenOptions.profile, similarly forRunOptions) is an API-breaking change for downstream users constructing these via struct literals. If maintaining source compatibility is important, consider making the field optional with a defaulting accessor, or providing constructor/builder APIs and marking the struct#[non_exhaustive]to discourage literal construction going forward.
#[derive(Debug, Clone)]
pub struct OpenOptions {
pub shell: Option<Shell>,
/// Terminal settings, already resolved from the config file by the
/// client. The daemon never reads that file: it is long-lived and shared,
/// so it has no single working directory to resolve a project-local config
/// against.
pub profile: crate::profile::Profile,
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
- Files reviewed: 17/18 changed files
- Comments generated: 0 new
- Review effort level: Lite
cpendery (cpendery)
left a comment
There was a problem hiding this comment.
Overall looks good, one gap to call out is the handling of dim / inverse. The SVG renderer still handles these properly, but the assertions are just based on the raw SGR state.
We should at least use the transformed color for the assertions. Additionally, adding --dim / --inverse flags would allow for easier assertions for the end user on those transformed colors
| shell-use open --config ./other.toml --profile ci | ||
| ``` | ||
|
|
||
| Looked up nearest first: `./shell-use.toml`, then |
There was a problem hiding this comment.
I think we should also honor XDG_CACHE_HOME if we are adding a configuration file
| /// Config file to read (default: ./shell-use.toml, then | ||
| /// ~/.shell-use/shell-use.toml). | ||
| #[arg(long, value_name = "PATH")] | ||
| pub config: Option<std::path::PathBuf>, |
There was a problem hiding this comment.
If we are adding a config, we should add some validation to it, ex: validating that we are getting valid colors for the profile so we don't have random crashes
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.rshad a privateThemewhere red was#e88388;assert/color.rshad anANSI16table where red was#800000. Nothing kept them in sync and nothing noticed they had drifted.The bug
Same program, same red text, on
main: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.
#e88388#800000Both tables are deleted. One
Colorstype 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.
--configpicks the file and--profilethe entry; discovery cascades./shell-use.tomlthen~/.shell-use/shell-use.toml.$ shell-use run --config shell-use.toml --profile solarized -- bashA 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
scrollbackdefaults 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#rrggbbresolves through the palette."fg": 1), not RGB, so existing baselines survive a palette change. There are tests pinning that.#c0c0c0on background#000000, which is what the assertion side already used, so no assertion changes meaning.