Skip to content

feat(tui): OSC 11 background-color query for theme auto-detect (theme iter 3)#183

Merged
gerchowl merged 1 commit intodevfrom
feat/176-osc11-detect
Apr 28, 2026
Merged

feat(tui): OSC 11 background-color query for theme auto-detect (theme iter 3)#183
gerchowl merged 1 commit intodevfrom
feat/176-osc11-detect

Conversation

@gerchowl
Copy link
Copy Markdown
Contributor

Closes #176.

Summary

Iter 3 of the theme auto-detect chain. Resolver order is now:

flag > env > config > COLORFGBG > OSC 11 > dark default

Iter 2 (#151) shipped the COLORFGBG step and explicitly deferred OSC 11 because it needed raw-tty handling and a millisecond-grained timeout. This lands that piece on its own.

  • New theme::osc11 submodule. Pure helpers (parse_osc11_reply, classify_rgb) plus a unix query_terminal_background driver that emits \x1b]11;?\x07, time-bounds the stdin read with libc::poll, parses the rgb:RRRR/GGGG/BBBB or rgb:RR/GG/BB reply with BEL or ST terminator, and classifies by CCIR-601 luminance against threshold 128.
  • Theme module promoted to a directory: theme.rs -> theme/mod.rs.
  • detect_terminal_background now chains COLORFGBG then OSC 11; each probe is isolated for testability.
  • 150ms timeout const at module top with a breadcrumb to promote it to SCITADEL_OSC11_TIMEOUT_MS later if slow ssh links complain.

Safety / correctness

  • Probe is gated behind IsTerminal on stdin AND stdout — no query fires under cargo test, cron, or redirected I/O.
  • Termios is saved with tcgetattr before flipping to non-canonical / no-echo and unconditionally restored before return.
  • libc::poll enforces the millisecond deadline; read is only called after POLLIN so the call is non-blocking by construction.
  • Read loops on partial replies (tmux pass-through can fragment) but always honors the deadline.
  • On any failure path (non-tty, timeout, malformed, stdio error) the function returns None and the resolver falls through to dark.

Dependencies

Zero new entries in Cargo.locklibc is already transitive via crossterm/tokio/mio. Added as a direct cfg(unix)-gated dep on scitadel-tui so the module can call libc::poll / tcgetattr / tcsetattr directly.

Test plan

Automated (12 new tests; 50 total in scitadel-tui, full workspace green):

  • parse_osc11_reply x6: 4-byte BEL light bg, 2-byte BEL dark bg, 4-byte ST terminator, malformed (5 sub-cases), empty buffer, leading-noise tolerance.
  • classify_rgb x7: black, white, warm-cream Dalton-bright bg, Solarized-dark bg, pure red, pure green, threshold edge case.
  • osc11_skipped_when_stdin_not_a_tty: resolve(None, "auto") returns under 50ms (well below the 150ms timeout) when stdin is a pipe — fences against the resolver ever hanging the test runner. Reuses the existing ENV_LOCK (fix(tui): serialize env-mutating theme tests (#165) #169 carry-over) so it serializes with the COLORFGBG / SCITADEL_THEME mutating tests.
  • cargo fmt --all -- --check
  • cargo clippy --workspace --tests -- -D warnings
  • cargo test --workspace

Manual (requires a real OSC-11-supporting tty; not fakeable in CI):

  • SCITADEL_THEME= COLORFGBG= scitadel tui on foot/kitty/alacritty/modern xterm — light terminal toast reports dalton-bright (auto), dark terminal reports dalton-dark (auto).
  • Same on a terminal that ignores OSC 11 (older tmux without pass-through) — startup delay <= 150ms then dalton-dark (auto).

[tape-exempt: detection runs at startup before TUI render; observable behavior verified by parser/luminance unit tests; full e2e requires a real OSC-11-supporting terminal]

Closes #176.

Iter 3 of the theme auto-detect chain. Resolver order is now:

  flag > env > config > COLORFGBG > OSC 11 > dark default

Iter 2 (#151) shipped the COLORFGBG step and explicitly deferred OSC 11
because it needed raw-tty handling and a millisecond-grained timeout.
This lands that piece on its own.

## What

- New `theme::osc11` submodule. Pure helpers (`parse_osc11_reply`,
  `classify_rgb`) plus the unix `query_terminal_background` driver:
  emit `\x1b]11;?\x07`, time-bound the stdin read with `libc::poll`,
  parse the `rgb:RRRR/GGGG/BBBB` (4-byte-hex) or `rgb:RR/GG/BB`
  (2-byte) reply with either BEL or ST terminator, classify by
  CCIR-601 luminance against threshold 128.
- Theme directory: `theme.rs` -> `theme/mod.rs` so the OSC 11 helper
  can live next to it without polluting `lib.rs`.
- `detect_terminal_background` now chains COLORFGBG, then OSC 11. Each
  probe is isolated for testability.
- 150ms timeout const at module top with a breadcrumb to promote it to
  `SCITADEL_OSC11_TIMEOUT_MS` later if slow ssh links complain.

## Safety / correctness

- Probe is gated behind `IsTerminal` on stdin AND stdout — no query
  fires under cargo test, cron, redirected I/O.
- Termios is saved with `tcgetattr` before flipping to non-canonical/
  no-echo and unconditionally restored before return; OSC reply bytes
  arrive immediately and aren't echoed back to the user.
- `libc::poll` enforces the millisecond deadline; `read` is only
  called after `POLLIN` so the call is non-blocking by construction.
- Read loops on partial replies (tmux pass-through can fragment) but
  always honors the deadline.
- On any failure path (non-tty, timeout, malformed, stdio error) the
  function returns `None` and the resolver falls through to dark.

## Tests (12 new, 50 total in scitadel-tui)

Pure-function tests:
- `parse_osc11_reply` x6: 4-byte BEL light bg, 2-byte BEL dark bg,
  4-byte ST terminator, malformed (5 sub-cases), empty buffer,
  leading-noise tolerance.
- `classify_rgb` x7: black, white, warm-cream Dalton-bright bg,
  Solarized-dark bg, pure red, pure green, threshold edge case.

Integration:
- `osc11_skipped_when_stdin_not_a_tty`: confirms `resolve(None,
  "auto")` returns under 50ms (well below the 150ms OSC 11 timeout)
  when stdin is a pipe — guards against the resolver ever hanging
  the test runner. Reuses the existing `ENV_LOCK` so it serializes
  with the COLORFGBG / SCITADEL_THEME mutators (#169 carry-over).

End-to-end on a live OSC-11-capable terminal is intentionally manual:
the round-trip needs a real tty that answers the query. Verify with:

    SCITADEL_THEME= COLORFGBG= scitadel tui

on foot/kitty/alacritty/modern xterm — toast should report
`dalton-bright (auto)` on a light terminal, `dalton-dark (auto)` on
a dark one, and `dalton-dark (auto)` (after ~150ms) on a terminal
that ignores OSC 11.

## Dependencies

Zero new entries in `Cargo.lock` — `libc` is already pulled in
transitively by crossterm/tokio/mio. The change adds it as a direct
target-gated (`cfg(unix)`) dep on scitadel-tui so we can call
`libc::poll` / `libc::tcgetattr` / `libc::tcsetattr` without reaching
through another crate's surface.

[tape-exempt: detection runs at startup before TUI render; observable
behavior verified by parser/luminance unit tests; full e2e requires a
real OSC-11-supporting terminal]
@gerchowl gerchowl merged commit 98e8b91 into dev Apr 28, 2026
11 of 12 checks passed
@gerchowl gerchowl deleted the feat/176-osc11-detect branch April 28, 2026 13:05
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