Skip to content
Closed
6 changes: 6 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ stable across profiles.
The palette is what a screenshot paints **and** what `expect --fg/--bg` matches
a `#rrggbb` against, so the two always agree.

Programs can also set and query colours at runtime with `OSC 4/10/11/12` and
reset them with `OSC 104/110/111/112`. A query is answered with the colour
currently showing; a reset restores the profile's colour, which no escape
sequence can change. Note that a program setting a colour also changes what a
screenshot of that session looks like.

## Supported shells & integration

`open --shell S` accepts: `bash`, `zsh`, `fish`, `powershell`, `pwsh`, `cmd`,
Expand Down
2 changes: 1 addition & 1 deletion crates/shell-use-cli/src/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ mod tests {
];

for want in styles {
let mut emu = AlacrittyEmu::new(10, 2, 0);
let mut emu = AlacrittyEmu::new(10, 2, &shell_use::profile::Profile::default());
emu.process(want.sgr().as_bytes());
emu.process(b"x");
let got = Style::from(&emu.viewable_rows()[0][0]);
Expand Down
97 changes: 97 additions & 0 deletions crates/shell-use-cli/tests/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,103 @@ fn an_unknown_profile_is_rejected() {
);
}

/// A program that asks the terminal what color it is gets an answer.
///
/// This is how tools decide whether they are on a light or a dark background.
/// A terminal that stays silent leaves them blocked until they time out and
/// guess, so this drives the whole path: daemon, emulator, and the reply on
/// its way back up the PTY.
///
/// Unix only, because the probe has to put its own terminal in raw mode to
/// read a reply that arrives without a newline and must not be echoed, and
/// `termios` does not exist on Windows CPython. The reply itself is not
/// platform specific: how it is formatted is covered by conformance cases
/// that run against every backend, and the write that carries it to the child
/// is the same `pty.write` every `type` and `submit` on Windows already uses.
#[cfg(unix)]
#[test]
fn a_color_query_is_answered_over_the_pty() {
let sandbox = Sandbox::new("osc-query");
let probe = sandbox.home.join("probe.py");
std::fs::write(
&probe,
r#"
import os, sys, termios, tty, select

# Unbuffered reads: a buffered reader would take bytes off the fd that
# select() then cannot see, and the reply would look truncated.
def ask(fd, query):
os.write(1, query)
buf = b""
while select.select([fd], [], [], 2.0)[0]:
buf += os.read(fd, 64)
if buf.endswith(b"\x07"):
break
return buf.decode("utf8", "replace")

fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
configured = ask(fd, b"\x1b]11;?\x07")
# Every dynamic colour, not just the background: a program that sets the
# foreground and cursor has to be answered about those too.
os.write(1, b"\x1b]10;#abcdef\x07\x1b]11;#654321\x07\x1b]12;#fedcba\x07")
fg = ask(fd, b"\x1b]10;?\x07")
overridden = ask(fd, b"\x1b]11;?\x07")
cursor = ask(fd, b"\x1b]12;?\x07")
os.write(1, b"\x1b]111\x07")
restored = ask(fd, b"\x1b]11;?\x07")
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)

strip = lambda s: s.replace("\x1b", "").replace("\x07", "")
print("\r\nRESULT %s %s %s %s %s\r" % (
strip(configured), strip(fg), strip(overridden), strip(cursor), strip(restored)))
"#,
)
.expect("write probe");

// Wide enough that the report is one unwrapped line: `text` returns the
// grid, so a wrapped reply would be split across rows.
sandbox.ok(&["run", "--cols", "200", "--", "bash", "--norc"]);
sandbox.ok(&[
"submit",
&format!("python3 {}", probe.to_str().expect("utf-8 path")),
]);
sandbox.ok(&["wait", "command"]);
let text = sandbox.ok(&["text", "--full"]);

let line = text
.lines()
.find(|l| l.contains("RESULT"))
.unwrap_or_else(|| panic!("the probe never reported: {text}"));

// The default profile's background is black, so the terminal reports it,
// then the color the program set, then the configured one again.
assert!(
line.contains("]11;rgb:0000/0000/0000"),
"the configured background should be reported: {line}"
);
assert!(
line.contains("]11;rgb:6565/4343/2121"),
"a set background should be reported back: {line}"
);
assert!(
line.contains("]10;rgb:abab/cdcd/efef"),
"a set foreground should be reported back: {line}"
);
assert!(
line.contains("]12;rgb:fefe/dcdc/baba"),
"a set cursor color should be reported back: {line}"
);
assert_eq!(
line.matches("]11;rgb:0000/0000/0000").count(),
2,
"a reset should restore the configured background: {line}"
);
}

#[test]
fn state_reports_effective_timeouts() {
let sandbox = Sandbox::new("state-timeouts");
Expand Down
82 changes: 73 additions & 9 deletions crates/shell-use/src/assert/color.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Color parsing and comparison for `expect --fg/--bg`.

use super::super::terminal::cell::Color;
use crate::profile::Colors;
use crate::terminal::emu::Emulator;

/// The spelling of [`Expected::Default`], on the command line and in messages.
pub const DEFAULT: &str = "default";
Expand Down Expand Up @@ -74,7 +74,7 @@ fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> {
/// the screenshot renderer draws with. These used to be two separate hardcoded
/// tables that disagreed on every ANSI slot, so `expect --fg "#800000"` passed
/// on a cell a screenshot painted `#e88388`.
pub fn matches(cell: Option<Color>, expected: &Expected, colors: &Colors) -> bool {
pub fn matches(cell: Option<Color>, expected: &Expected, colors: &dyn Emulator) -> bool {
let Some(cell) = cell else {
return matches!(expected, Expected::Default);
};
Expand All @@ -89,7 +89,7 @@ pub fn matches(cell: Option<Color>, expected: &Expected, colors: &Colors) -> boo
}

/// Render a cell's color in the same space as the expected value, for messages.
pub fn describe_cell(cell: Option<Color>, expected: &Expected, colors: &Colors) -> String {
pub fn describe_cell(cell: Option<Color>, expected: &Expected, colors: &dyn Emulator) -> String {
let Some(cell) = cell else {
return DEFAULT.to_string();
};
Expand Down Expand Up @@ -124,7 +124,23 @@ pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::{Colors, Profile};
use crate::terminal::alacritty::AlacrittyEmu;
use crate::terminal::cell::Color;
use crate::terminal::emu::Emulator;

/// A real emulator, so these exercise the same resolution path a session
/// uses rather than a stand-in that could drift from it.
fn emu_with(colors: Colors) -> AlacrittyEmu {
AlacrittyEmu::new(
10,
2,
&Profile {
colors,
..Default::default()
},
)
}

#[test]
fn parse_forms() {
Expand All @@ -144,7 +160,7 @@ mod tests {

#[test]
fn matches_palette_and_default() {
let c = Colors::default();
let c = emu_with(Colors::default());
let idx = |i| Some(Color::from_index(i));
assert!(matches(idx(9), &Expected::Ansi256(9), &c));
assert!(!matches(idx(2), &Expected::Ansi256(9), &c));
Expand All @@ -161,7 +177,7 @@ mod tests {
/// `default` keyword, which is the way to assert on it.
#[test]
fn default_color_matches_only_default() {
let c = Colors::default();
let c = emu_with(Colors::default());
assert!(!matches(None, &Expected::Ansi256(0), &c));
assert!(!matches(None, &Expected::Hex(0, 0, 0), &c));
assert!(matches(None, &Expected::Default, &c));
Expand All @@ -170,7 +186,7 @@ mod tests {

#[test]
fn a_colored_cell_is_not_default() {
let c = Colors::default();
let c = emu_with(Colors::default());
let red = Some(Color::from_index(1));
assert!(!matches(red, &Expected::Default, &c));
assert!(matches(red, &Expected::Ansi256(1), &c));
Expand All @@ -190,7 +206,7 @@ mod tests {
/// value for every slot, because both come from the profile.
#[test]
fn an_assertion_matches_the_color_a_screenshot_paints() {
let colors = Colors::default();
let colors = emu_with(Colors::default());
for index in 0u8..=255 {
let cell = Some(Color::from_index(index));
let painted = colors.resolve(cell, true);
Expand All @@ -206,14 +222,62 @@ mod tests {
}
}

/// An assertion compares against what the terminal is *currently*
/// showing, so a program that recolors a slot changes what matches.
///
/// This is the other half of the screenshot test: both read the same
/// state, so a colour a screenshot paints is a colour an assertion
/// matches, at every point in a session rather than only at the start.
#[test]
fn an_assertion_follows_a_color_a_program_set() {
use crate::terminal::emu::Emulator;
let mut emu = emu_with(Colors::default());
let red = Some(Color::from_index(1));
let configured = Colors::default().red;

assert!(matches(
red,
&Expected::Hex(configured.r, configured.g, configured.b),
&emu
));

emu.process(b"\x1b]4;1;#22c55e\x07");
assert!(
matches(red, &Expected::Hex(0x22, 0xc5, 0x5e), &emu),
"the assertion follows the colour the program set"
);
assert!(
!matches(
red,
&Expected::Hex(configured.r, configured.g, configured.b),
&emu
),
"the configured colour is no longer what slot 1 shows"
);
assert!(
matches(red, &Expected::Ansi256(1), &emu),
"the index is unaffected: it names a slot, not a colour"
);

emu.process(b"\x1b]104;1\x07");
assert!(
matches(
red,
&Expected::Hex(configured.r, configured.g, configured.b),
&emu
),
"a reset restores the configured colour"
);
}

/// A profile's palette is what an assertion compares against, so two
/// profiles genuinely disagree rather than sharing one hardcoded table.
#[test]
fn a_recolored_profile_moves_what_an_assertion_matches() {
let colors = Colors {
let colors = emu_with(Colors {
red: crate::profile::Rgb::new(1, 2, 3),
..Default::default()
};
});
let red = Some(Color::from_index(1));
assert!(matches(red, &Expected::Hex(1, 2, 3), &colors));
assert!(!matches(red, &Expected::Hex(128, 0, 0), &colors));
Expand Down
37 changes: 37 additions & 0 deletions crates/shell-use/src/assert/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,43 @@ mod tests {
use super::*;
use crate::terminal::cell::CONTINUATION;

/// A snapshot records the palette *slot* a cell chose, never the color
/// that slot resolves to.
///
/// This is what lets a saved baseline outlive a profile change: the same
/// screen recorded under two profiles that disagree about what red looks
/// like still produces the same snapshot, so recoloring a terminal does
/// not invalidate every snapshot in a suite.
#[test]
fn a_snapshot_records_the_slot_rather_than_the_color() {
let colored = EmuCell {
ch: "x".into(),
fg: Some(Color::from_index(1)),
..EmuCell::blank()
};
let out = serialize(&[vec![colored]], 1, true);
assert!(
out.contains("\"fg\": 1"),
"the slot is recorded, not an rgb value: {out}"
);
assert!(
!out.contains('#'),
"a palette color must not be resolved into the snapshot: {out}"
);
}

/// A true-color cell names its own color, so that one *is* recorded
/// literally: no profile can change what `38;2;r;g;b` means.
#[test]
fn a_true_color_cell_records_its_own_value() {
let rgb = EmuCell {
ch: "x".into(),
fg: Some(Color::Rgb(0x11, 0x22, 0x33)),
..EmuCell::blank()
};
assert!(serialize(&[vec![rgb]], 1, true).contains("#112233"));
}

fn cell(s: &str) -> EmuCell {
EmuCell {
ch: s.into(),
Expand Down
28 changes: 23 additions & 5 deletions crates/shell-use/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1007,9 +1007,18 @@ fn expect_text(
|| {
matched = match locator::find(&grid(session, full), &pattern, strict) {
Ok(Some(cells)) if !cells.is_empty() => {
if let Some(error) =
check_colors(&cells, &fg, &bg, not, &session.profile.colors)
{
if let Some(error) = check_colors(
&cells,
&fg,
&bg,
not,
session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.emu
.as_ref(),
) {
last_error = Some(error);
false
} else {
Expand Down Expand Up @@ -1050,7 +1059,7 @@ fn check_colors(
fg: &Option<String>,
bg: &Option<String>,
not: bool,
colors: &crate::profile::Colors,
colors: &dyn crate::terminal::emu::Emulator,
) -> Option<String> {
let want = !not;
if let Some(spec) = fg {
Expand Down Expand Up @@ -1177,7 +1186,16 @@ fn screenshot(
let rows = grid(session, full);
match path {
Some(path) => {
let svg = crate::render::svg::render_svg(&rows, session.cols, &session.profile.colors);
let svg = crate::render::svg::render_svg(
&rows,
session.cols,
session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.emu
.as_ref(),
);
std::fs::write(&path, svg)
.map_err(|error| ShellUseError::internal(error.to_string()))?;
Ok(ScreenshotResult::Path(path))
Expand Down
Loading
Loading