Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
105 changes: 105 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,111 @@ 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")),
]);
// Wait for the line this test reads, not for the command.
//
// The probe prints nothing until it is done: its queries go to the
// terminal, which answers them rather than echoing them, so the screen
// stays unchanged for as long as python takes to start. `bash --norc` has
// no shell integration, so `wait command` falls back to "the prompt came
// back and the screen is idle", and on a loaded machine an idle screen
// arrives long before the report does.
sandbox.ok(&["wait", "text", "RESULT", "--timeout", "30000"]);
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
Comment on lines +1192 to +1196

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.

The structure is as you describe — the reader thread does need this lock to process — so I measured how long it is actually held. Release build, timing render_svg alone:

screenshot rows lock held
typical, 80x24 25 71 µs
--full, 10k scrollback 10001 15.9 ms

The file write is already outside the lock; only the render is inside.

So the common case is 71 µs, and even a full render at the default scrollback is 16 ms — well inside the seconds a program waits for a query reply, and small against a PTY buffer. It scales with scrollback, so a much deeper one would make it worse.

I have not changed it, for a reason worth flagging: the fix you describe is a snapshot of the resolved palette, and this PR deliberately removed exactly that type. Colors resolve through the emulator now precisely so a screenshot shows what OSC set at that moment, and reintroducing a copied palette is the shape we took out. Given 71 µs in the common case I would rather not trade that back without a measurement showing it hurts.

Happy to revisit if you would rather have the shorter critical section — say the word and I will do it in a follow-up so it can be reviewed on its own.

.as_ref(),
);
std::fs::write(&path, svg)
.map_err(|error| ShellUseError::internal(error.to_string()))?;
Ok(ScreenshotResult::Path(path))
Expand Down
Loading
Loading