M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,655 @@
//! In-memory theme cache + resolution.
//!
//! The pager reads the active `ThemeKind` on every render frame, so the
//! lookup must be cheaper than re-loading from `~/.kigi/config.toml`.
//! [`current_kind`] returns the in-memory value, lazily seeding from the
//! shell's layered effective config on first call.
//!
//! Disk writes are NOT performed here — they live in
//! `kigi_shell::util::config::set_theme()` (and friends), invoked
//! via `Effect::PersistSetting` from the dispatcher. This module is a
//! pager-side in-memory cache + resolution layer only.
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use super::ThemeKind;
use super::system_appearance;
/// In-memory theme kind, encoded as a `u8` matching the
/// `ThemeKind` discriminants. Loaded from disk once at startup via
/// `load_from_disk()`, then kept in sync by `set()`.
static CURRENT: AtomicU8 = AtomicU8::new(ThemeKind::GrokNight as u8);
static LOADED: AtomicBool = AtomicBool::new(false);
#[cfg(any(test, feature = "test-support"))]
static TEST_LOCK: Mutex<()> = Mutex::new(());
/// Whether auto-switching mode is active. Set when the config file
/// contains `theme = "auto"`. Checked by the event loop to decide
/// whether the `SystemAppearanceWatcher` should run.
///
/// Uses `AtomicBool` for thread-safe access from the watcher task.
static AUTO_MODE: AtomicBool = AtomicBool::new(false);
/// Whether the theme is locked to `Theme::terminal_default` for the whole
/// session (minimal mode — no theming).
static TERMINAL_NATIVE_LOCK: AtomicBool = AtomicBool::new(false);
/// Decode the u8 stored in `CURRENT` back to a `ThemeKind`. Falls
/// back to `GrokNight` if the byte is somehow out of range (which
/// can't happen via `set` — the discriminant is always a valid
/// variant — but defends against a future variant addition that
/// forgot to extend this match).
fn theme_kind_from_u8(byte: u8) -> ThemeKind {
match byte {
x if x == ThemeKind::GrokNight as u8 => ThemeKind::GrokNight,
x if x == ThemeKind::GrokDay as u8 => ThemeKind::GrokDay,
x if x == ThemeKind::TokyoNight as u8 => ThemeKind::TokyoNight,
x if x == ThemeKind::RosePineMoon as u8 => ThemeKind::RosePineMoon,
x if x == ThemeKind::OscuraMidnight as u8 => ThemeKind::OscuraMidnight,
x if x == ThemeKind::Auto as u8 => ThemeKind::Auto,
_ => ThemeKind::GrokNight,
}
}
/// Cached auto-theme configuration (which themes map to dark/light).
///
/// Uses `Mutex<Option<_>>` rather than `OnceLock` so the cache can be
/// invalidated when the user changes mappings via the settings modal
/// or the `/theme auto` slash command.
static AUTO_THEME_CONFIG: Mutex<Option<AutoThemeConfig>> = Mutex::new(None);
/// Auto-theme config: which themes map to dark/light system appearance.
///
/// `dark_theme` and `light_theme` are the user-configured overrides read
/// from `[ui].auto_dark_theme` and `[ui].auto_light_theme` in `config.toml`.
/// When `None`, `to_theme_kind()` defaults to `GrokNight` / `GrokDay`.
#[derive(Debug, Clone, Copy, Default)]
pub struct AutoThemeConfig {
pub dark_theme: Option<ThemeKind>,
pub light_theme: Option<ThemeKind>,
}
/// Get the current theme kind.
///
/// On the first call, reads from `~/.kigi/config.toml` (via the shell's
/// `load_effective_config`). After that, returns the in-memory value
/// (updated by [`set`]).
pub fn current_kind() -> ThemeKind {
// Locked: return a constant nominal kind without seeding from disk.
if terminal_native_locked() {
return ThemeKind::GrokNight;
}
if !LOADED.load(Ordering::Acquire) {
// Two threads racing into the seed path is harmless — the
// disk read is idempotent and `store` is atomic. Worst case
// both threads call `load_from_disk` once.
if let Some(kind) = load_from_disk() {
CURRENT.store(kind as u8, Ordering::Relaxed);
}
LOADED.store(true, Ordering::Release);
}
theme_kind_from_u8(CURRENT.load(Ordering::Relaxed))
}
/// Set the in-memory theme kind without writing to disk.
///
/// Used by the dispatcher (after `Action::SetTheme` is processed) and
/// by the live-preview path during the picker. Disk-write happens via
/// `Effect::PersistSetting`, NOT here.
pub fn set(kind: ThemeKind) {
CURRENT.store(kind as u8, Ordering::Relaxed);
LOADED.store(true, Ordering::Release);
}
// -- Terminal-native lock (minimal mode) --------------------------------------
/// Whether the theme is locked to the terminal-native palette.
#[must_use]
pub fn terminal_native_locked() -> bool {
TERMINAL_NATIVE_LOCK.load(Ordering::Relaxed)
}
/// Engage or clear the terminal-native theme lock.
pub fn set_terminal_native_lock(locked: bool) {
TERMINAL_NATIVE_LOCK.store(locked, Ordering::Relaxed);
kigi_markdown::set_color_level_cap(if locked {
kigi_markdown::ColorLevel::Basic
} else {
kigi_markdown::ColorLevel::TrueColor
});
}
// -- Auto-mode ---------------------------------------------------------------
/// Whether auto-switching mode is active.
#[must_use]
pub fn is_auto_mode() -> bool {
AUTO_MODE.load(Ordering::Relaxed)
}
/// Set or clear auto-switching mode.
pub fn set_auto_mode(enabled: bool) {
AUTO_MODE.store(enabled, Ordering::Relaxed);
}
/// Get the cached auto-theme configuration, loading from config on first access.
///
/// The cache can be invalidated via [`invalidate_auto_theme_config`] so
/// subsequent lookups re-read from disk.
#[must_use]
pub fn auto_theme_config() -> AutoThemeConfig {
let mut guard = AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner());
*guard.get_or_insert_with(load_auto_theme_config)
}
/// Invalidate the cached auto-theme configuration.
///
/// Call after updating `auto_dark_theme` or `auto_light_theme` in config
/// so subsequent lookups see the new values. Used by the settings modal
/// and the `/theme auto` slash command.
pub fn invalidate_auto_theme_config() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
// -- Theme resolution --------------------------------------------------------
/// Resolve the effective theme, respecting the full precedence chain.
///
/// Called once at startup. Returns the concrete `ThemeKind` (never `Auto`).
///
/// Precedence:
/// 1. Environment variable (`KIGI_THEME`)
/// 2. Config file (`[ui].theme`)
/// 3. Default: `GrokNight`
#[must_use]
pub fn resolve_initial_theme() -> ThemeKind {
// 1. Environment variable (for desktop app integration)
// 2. Config file + 3. Default
resolve_from_config(load_from_disk(), true)
}
/// Inner resolution logic, factored out for testability.
fn resolve_from_config(config_theme: Option<ThemeKind>, osc11_fallback: bool) -> ThemeKind {
if let Some(kind) = config_theme {
if kind.is_auto() {
set_auto_mode(true);
let appearance = if osc11_fallback {
system_appearance::detect_with_osc11_fallback()
} else {
system_appearance::detect()
};
return resolve_from_appearance(appearance);
}
return kind;
}
// Default: GrokNight
ThemeKind::GrokNight
}
/// Map an optional appearance detection result to a concrete `ThemeKind`.
fn resolve_from_appearance(appearance: Option<system_appearance::SystemAppearance>) -> ThemeKind {
let config = auto_theme_config();
appearance
.map(|a| system_appearance::to_theme_kind(a, config.dark_theme, config.light_theme))
.unwrap_or(ThemeKind::GrokNight)
}
/// Resolve "auto" by detecting system appearance and mapping via config.
///
/// Returns the concrete `ThemeKind` based on the current system appearance
/// and the user's dark/light theme mapping. Falls back to `GrokNight`
/// when detection fails.
///
/// Uses desktop APIs only (no OSC 11) — safe to call at runtime while
/// crossterm's `EventStream` is active. Called from the settings modal
/// and the `/theme auto` slash command.
#[must_use]
pub fn resolve_auto() -> ThemeKind {
resolve_from_appearance(system_appearance::detect())
}
/// Variant of [`resolve_initial_theme`] without the OSC 11 startup
/// fallback, for resolution after the terminal is initialized.
#[must_use]
pub fn resolve_initial_theme_no_osc11() -> ThemeKind {
resolve_from_config(load_from_disk(), false)
}
// -- Disk reads --------------------------------------------------------------
//
// All writes go through `kigi_shell::util::config::set_theme()` (and
// friends) via `Effect::PersistSetting`. This module only READS from the
// shell's layered effective config.
/// Read the theme from the effective config (managed_config.toml merged
/// under config.toml — user wins).
///
/// Checks `[ui].theme` first (the canonical location), then falls back
/// to a top-level `theme` key for backwards compatibility.
fn load_from_disk() -> Option<ThemeKind> {
let root = kigi_config::load_effective_config_disk_only().ok()?;
let table = root.as_table()?;
// Canonical: [ui] section
let value = table
.get("ui")
.and_then(|ui| ui.get("theme"))
.and_then(|v| v.as_str())
// Fallback: top-level `theme` key (legacy)
.or_else(|| table.get("theme").and_then(|v| v.as_str()));
value.and_then(ThemeKind::from_name)
}
/// Load auto-theme configuration from the effective config.
///
/// Reads `[ui].auto_dark_theme` and `[ui].auto_light_theme`, parsing them
/// as theme names. Filters out `Auto` to prevent circular reference.
fn load_auto_theme_config() -> AutoThemeConfig {
let Ok(root) = kigi_config::load_effective_config_disk_only() else {
return AutoThemeConfig::default();
};
let Some(table) = root.as_table() else {
return AutoThemeConfig::default();
};
let ui = table.get("ui");
AutoThemeConfig {
dark_theme: ui
.and_then(|u| u.get("auto_dark_theme"))
.and_then(|v| v.as_str())
.and_then(ThemeKind::from_name)
.filter(|k| !k.is_auto()),
light_theme: ui
.and_then(|u| u.get("auto_light_theme"))
.and_then(|v| v.as_str())
.and_then(ThemeKind::from_name)
.filter(|k| !k.is_auto()),
}
}
// -- Test support ------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
pub fn reset_for_test() {
// Tests are serialized via TEST_LOCK so the AtomicU8/AtomicBool
// pair is safe to reset without any cross-thread coordination.
CURRENT.store(ThemeKind::GrokNight as u8, Ordering::Relaxed);
LOADED.store(false, Ordering::Release);
AUTO_MODE.store(false, Ordering::Relaxed);
set_terminal_native_lock(false);
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// Seed `AUTO_THEME_CONFIG` with explicit defaults so `auto_theme_config()`
/// never falls through to `load_auto_theme_config()` (which reads the
/// user's real `config.toml`). Call from test setup after `reset_for_test()`.
#[cfg(any(test, feature = "test-support"))]
pub fn seed_auto_theme_defaults_for_test() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(AutoThemeConfig::default());
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_lock() -> &'static Mutex<()> {
&TEST_LOCK
}
/// Pin a deterministic theme + color level for a test's duration so exact
/// height / screen-position assertions are hermetic. Rendered heights are
/// computed under the process-global `Theme::current()` (which concurrent
/// `set_theme` tests mutate) and `Theme::current()` reads the global color
/// level; holding the shared test lock blocks a mid-test theme change. Hold the
/// returned guard for the whole test.
#[cfg(any(test, feature = "test-support"))]
pub fn pin_theme() -> std::sync::MutexGuard<'static, ()> {
let guard = test_lock().lock().unwrap_or_else(|e| e.into_inner());
set(ThemeKind::GrokNight);
// Color level is a write-once `OnceLock`; tests run without a TTY so it
// resolves to `TrueColor` anyway. Pin it explicitly (best-effort: ignore the
// already-initialized `Err`) so the measure path that reads it stays fixed.
let _ = super::color_support::set(super::color_support::ColorLevel::TrueColor);
guard
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: run a test body while holding the global test lock and
/// with a clean initial state.
fn with_test_env(f: impl FnOnce()) {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_for_test();
seed_auto_theme_defaults_for_test();
// Set LOADED=true so current_kind() doesn't read from disk.
set(ThemeKind::GrokNight);
system_appearance::clear_mock();
f();
system_appearance::clear_mock();
reset_for_test();
}
/// Pre-populate the auto-theme config cache for testing.
fn set_test_auto_config(config: AutoThemeConfig) {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(config);
}
// -- Terminal-native lock (minimal mode) ----------------------------------
#[test]
fn terminal_native_lock_pins_kind_and_blocks_apply_kind() {
with_test_env(|| {
set(ThemeKind::GrokDay);
set_terminal_native_lock(true);
assert!(terminal_native_locked());
assert_eq!(current_kind(), ThemeKind::GrokNight, "nominal kind");
let applied = super::super::Theme::apply_kind(ThemeKind::GrokDay);
assert_eq!(applied, ThemeKind::GrokNight, "apply_kind must no-op");
assert_eq!(current_kind(), ThemeKind::GrokNight);
set_terminal_native_lock(false);
assert_eq!(
current_kind(),
ThemeKind::GrokDay,
"unlocking restores the cached kind"
);
});
}
#[test]
fn terminal_native_lock_serves_terminal_default_palette() {
with_test_env(|| {
set(ThemeKind::GrokDay);
set_terminal_native_lock(true);
let theme = super::super::Theme::current();
let native = super::super::Theme::terminal_default();
assert_eq!(theme.bg_base, native.bg_base);
assert_eq!(theme.text_primary, native.text_primary);
assert_eq!(theme.accent_user, native.accent_user);
assert_ne!(
theme.text_primary,
super::super::Theme::grokday().text_primary,
"must not serve the cached (GrokDay) theme"
);
});
}
#[test]
fn reset_for_test_clears_terminal_native_lock() {
with_test_env(|| {
set_terminal_native_lock(true);
reset_for_test();
assert!(!terminal_native_locked());
});
}
#[test]
fn terminal_native_lock_caps_quantize_at_ansi16() {
use ratatui::style::Color;
use crate::theme::color_support;
with_test_env(|| {
set_terminal_native_lock(true);
assert!(color_support::detect() <= color_support::ColorLevel::Basic);
for input in [
Color::Rgb(0x26, 0x26, 0x26), // grokday text_primary
Color::Rgb(122, 162, 247),
Color::Indexed(141),
] {
let q = color_support::quantize(input);
assert!(
!matches!(q, Color::Rgb(..) | Color::Indexed(_)),
"quantize({input:?}) must collapse to Reset/named ANSI under \
the lock, got {q:?}"
);
}
});
}
#[test]
fn resolve_no_osc11_explicit_auto_and_default() {
with_test_env(|| {
assert_eq!(
resolve_from_config(Some(ThemeKind::GrokDay), false),
ThemeKind::GrokDay
);
assert!(!is_auto_mode());
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
assert_eq!(
resolve_from_config(Some(ThemeKind::Auto), false),
ThemeKind::GrokDay
);
assert!(is_auto_mode(), "auto must arm the appearance watcher");
assert_eq!(resolve_from_config(None, false), ThemeKind::GrokNight);
});
}
// -- AUTO_MODE -----------------------------------------------------------
#[test]
fn auto_mode_default_is_false() {
with_test_env(|| {
assert!(!is_auto_mode());
});
}
#[test]
fn set_auto_mode_toggles() {
with_test_env(|| {
set_auto_mode(true);
assert!(is_auto_mode());
set_auto_mode(false);
assert!(!is_auto_mode());
});
}
// -- AutoThemeConfig -----------------------------------------------------
#[test]
fn auto_theme_config_defaults_to_none() {
let config = AutoThemeConfig::default();
assert!(config.dark_theme.is_none());
assert!(config.light_theme.is_none());
}
// -- resolve_auto --------------------------------------------------------
#[test]
fn resolve_auto_dark_system_returns_groknight() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokNight);
});
}
#[test]
fn resolve_auto_light_system_returns_grokday() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokDay);
});
}
#[test]
fn resolve_auto_detection_failure_returns_groknight() {
with_test_env(|| {
system_appearance::set_mock(None);
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokNight);
});
}
// -- invalidate_auto_theme_config ----------------------------------------
#[test]
fn invalidate_clears_cached_config() {
with_test_env(|| {
// Pre-populate the cache with a known config.
set_test_auto_config(AutoThemeConfig {
dark_theme: Some(ThemeKind::TokyoNight),
light_theme: None,
});
let config1 = auto_theme_config();
assert_eq!(config1.dark_theme, Some(ThemeKind::TokyoNight));
// Invalidate — next read re-loads (defaults in test env).
invalidate_auto_theme_config();
// Pre-populate again with defaults to avoid disk dependency.
set_test_auto_config(AutoThemeConfig::default());
let config2 = auto_theme_config();
assert!(config2.dark_theme.is_none());
});
}
// -- resolve_from_config (resolve_initial_theme inner logic) ---------------
#[test]
fn resolve_from_config_no_config_returns_groknight() {
with_test_env(|| {
let result = resolve_from_config(None, true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(!is_auto_mode());
});
}
#[test]
fn resolve_from_config_explicit_theme_returns_it() {
with_test_env(|| {
let result = resolve_from_config(Some(ThemeKind::GrokDay), true);
assert_eq!(result, ThemeKind::GrokDay);
assert!(
!is_auto_mode(),
"explicit theme should not enable auto mode"
);
});
}
#[test]
fn resolve_from_config_auto_sets_auto_mode_dark() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(is_auto_mode(), "auto config must enable auto mode");
});
}
#[test]
fn resolve_from_config_auto_with_light_system() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokDay);
assert!(is_auto_mode());
});
}
#[test]
fn resolve_from_config_auto_detection_failure() {
with_test_env(|| {
system_appearance::set_mock(None);
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(is_auto_mode(), "auto mode is set before detection");
});
}
// -- resolve_auto with custom config -------------------------------------
#[test]
fn resolve_auto_with_custom_dark_config() {
with_test_env(|| {
set_test_auto_config(AutoThemeConfig {
dark_theme: Some(ThemeKind::TokyoNight),
light_theme: None,
});
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
assert_eq!(resolve_auto(), ThemeKind::TokyoNight);
});
}
#[test]
fn resolve_auto_with_custom_light_config() {
with_test_env(|| {
set_test_auto_config(AutoThemeConfig {
dark_theme: None,
light_theme: Some(ThemeKind::RosePineMoon),
});
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
assert_eq!(resolve_auto(), ThemeKind::RosePineMoon);
});
}
// -- auto_theme_config filter --------------------------------------------
#[test]
fn auto_theme_config_filter_rejects_auto_value() {
// Simulates the .filter(|k| !k.is_auto()) guard in load_auto_theme_config().
// When config contains auto_dark_theme = "auto", from_name returns Some(Auto),
// but the filter discards it to prevent circular reference.
let parsed = ThemeKind::from_name("auto").filter(|k| !k.is_auto());
assert!(parsed.is_none(), "Auto must be filtered out");
}
#[test]
fn auto_theme_config_filter_accepts_concrete_theme() {
let parsed = ThemeKind::from_name("tokyonight").filter(|k| !k.is_auto());
assert_eq!(parsed, Some(ThemeKind::TokyoNight));
}
// -- set / current_kind --------------------------------------------------
/// `set` followed by `current_kind` returns the set value, and the
/// `LOADED` flag flips so subsequent reads don't re-seed from disk.
/// The optimistic-update invariant the dispatcher relies on.
///
/// Explicitly observe the `LOADED` flag
/// side-effect by calling `reset_for_test()` between sets — if
/// `set` didn't flip `LOADED = true`, the second `current_kind`
/// read would re-seed from disk and the assertion would fail.
#[test]
fn set_then_current_kind_round_trips() {
with_test_env(|| {
set(ThemeKind::TokyoNight);
assert_eq!(current_kind(), ThemeKind::TokyoNight);
set(ThemeKind::GrokDay);
assert_eq!(current_kind(), ThemeKind::GrokDay);
});
}
/// `set` flips `LOADED` so a subsequent `current_kind` read does
/// NOT re-seed from disk. Mirror of the
/// `set_then_current_kind_round_trips` test that the docstring
/// claims to enforce — exercises the `LOADED` flag invariant
/// directly via the atomic statics.
#[test]
fn set_flips_loaded_flag_so_current_kind_skips_disk_reseed() {
with_test_env(|| {
// with_test_env seeds LOADED=true to prevent disk reads;
// this test specifically needs LOADED=false to verify that
// set() flips it.
LOADED.store(false, Ordering::Release);
assert!(
!LOADED.load(Ordering::Acquire),
"LOADED must be false for this test"
);
set(ThemeKind::GrokDay);
assert!(
LOADED.load(Ordering::Acquire),
"set must flip LOADED to true"
);
// Subsequent current_kind read returns the set value (no
// disk re-seed).
assert_eq!(current_kind(), ThemeKind::GrokDay);
assert!(
LOADED.load(Ordering::Acquire),
"current_kind must NOT flip LOADED back to false"
);
});
}
}
@@ -0,0 +1,357 @@
//! Terminal color support detection and quantization.
//!
//! Detects the terminal's color capabilities (truecolor / 256 / 16 / none) and
//! provides a [`quantize_color`] function that downgrades a [`ratatui::style::Color`]
//! to the highest level the terminal supports.
//!
//! The detected level is cached in a global [`OnceLock`] — call [`detect`] once
//! at startup, then use [`get`] everywhere else.
use std::sync::OnceLock;
use ratatui::style::Color;
use crate::render::color::{indexed_to_rgb, nearest_indexed};
use crate::terminal::{TerminalName, terminal_context};
/// Terminal color support level (ordered low → high).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ColorLevel {
/// No color support (monochrome).
None,
/// Basic 16-color ANSI (SGR 3037 / 9097).
Basic,
/// 256-color indexed palette (SGR 38;5;N).
Ansi256,
/// 24-bit truecolor RGB (SGR 38;2;R;G;B).
TrueColor,
}
impl ColorLevel {
pub fn has_color(self) -> bool {
self >= Self::Basic
}
pub fn has_256(self) -> bool {
self >= Self::Ansi256
}
pub fn has_truecolor(self) -> bool {
self >= Self::TrueColor
}
/// Canonical lowercase spelling that round-trips through the
/// `KIGI_FORCE_COLOR_LEVEL` parser. Use this in user-facing
/// diagnostics (not `{:?}` Debug, which yields `Basic` / `Ansi256`
/// / `TrueColor` / `None`).
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Basic => "basic",
Self::Ansi256 => "256",
Self::TrueColor => "truecolor",
}
}
}
impl std::fmt::Display for ColorLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ── Global singleton ─────────────────────────────────────────────────────
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
/// Detect the terminal's color support and cache the result.
///
/// Uses the `supports-color` crate which checks `COLORTERM`, `TERM`,
/// terminal-specific env vars (`ITERM_SESSION_ID`, etc.) and whether
/// stdout is a TTY.
///
/// If `NO_COLOR` is set the result is [`ColorLevel::None`].
/// If stdout is not a TTY (test runner, piped output) and `NO_COLOR` is
/// absent, defaults to [`ColorLevel::TrueColor`] — the safe assumption
/// for a TUI app that always runs inside a terminal.
///
/// Capped at [`ColorLevel::Basic`] while the terminal-native lock is
/// engaged.
pub fn detect() -> ColorLevel {
let raw = detect_raw();
if crate::theme::cache::terminal_native_locked() {
return raw.min(ColorLevel::Basic);
}
raw
}
/// The raw cached detection, without the terminal-native lock cap.
fn detect_raw() -> ColorLevel {
*COLOR_LEVEL.get_or_init(|| {
// Explicit opt-out via NO_COLOR takes priority.
if std::env::var_os("NO_COLOR").is_some() {
return ColorLevel::None;
}
let level = match supports_color::on(supports_color::Stream::Stdout) {
Some(level) => {
if level.has_16m {
ColorLevel::TrueColor
} else if level.has_256 {
ColorLevel::Ansi256
} else if level.has_basic {
ColorLevel::Basic
} else {
ColorLevel::None
}
}
// Not a TTY (tests, piped) — default to TrueColor.
None => ColorLevel::TrueColor,
};
// The `supports-color` crate relies on COLORTERM=truecolor, but
// tmux/SSH/mosh often strip that variable. When the crate reports
// only 256-color support, upgrade to TrueColor if we can identify
// the terminal emulator and know it handles 24-bit RGB.
if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
return ColorLevel::TrueColor;
}
level
})
}
/// Return the cached color level (calls [`detect`] if not yet initialized).
pub fn get() -> ColorLevel {
detect()
}
/// Override the color level (useful for tests or `--color` flags).
///
/// Returns `Err` if already set.
pub fn set(level: ColorLevel) -> Result<(), ColorLevel> {
COLOR_LEVEL.set(level)
}
// ── Color quantization ──────────────────────────────────────────────────
/// Downgrade a [`Color`] to the highest representation the terminal supports.
///
/// | Terminal level | `Rgb` | `Indexed` | Named (`Red`…) |
/// |----------------|------------------|--------------------|----------------|
/// | TrueColor | pass-through | pass-through | pass-through |
/// | Ansi256 | → nearest idx | pass-through | pass-through |
/// | Basic | → nearest ANSI16 | → nearest ANSI16 | pass-through |
/// | None | → `Reset` | → `Reset` | → `Reset` |
pub fn quantize_color(color: Color, level: ColorLevel) -> Color {
match level {
ColorLevel::TrueColor => color,
ColorLevel::Ansi256 => match color {
Color::Rgb(r, g, b) => Color::Indexed(nearest_indexed(r, g, b)),
other => other,
},
ColorLevel::Basic => match color {
Color::Rgb(r, g, b) => indexed_to_ansi16(nearest_indexed(r, g, b)),
Color::Indexed(n) => indexed_to_ansi16(n),
other => other,
},
ColorLevel::None => Color::Reset,
}
}
/// Quantize a color using the globally-detected level.
pub fn quantize(color: Color) -> Color {
quantize_color(color, get())
}
// ── Terminal-based truecolor inference ──────────────────────────────────
/// Check whether the detected terminal emulator is known to support truecolor.
///
/// Used as a fallback when `COLORTERM` is missing (e.g. inside tmux, SSH, or
/// — most importantly — under a bare `cmd.exe` / `powershell.exe` ConHost
/// window, which has supported VT-encoded 24-bit color since Windows 10
/// 1709 (Fall Creators Update) but doesn't advertise it via COLORTERM. Without
/// this fallback our themes get quantized to the 16-color ANSI palette there
/// and the subtle bg/border/muted gradations collapse onto each other.
fn terminal_supports_truecolor() -> bool {
if matches!(
terminal_context().brand,
TerminalName::Iterm2
| TerminalName::Ghostty
| TerminalName::Kitty
| TerminalName::WezTerm
| TerminalName::Alacritty
| TerminalName::Rio
| TerminalName::WarpTerminal
| TerminalName::VsCode
| TerminalName::WindowsTerminal
| TerminalName::Foot
) {
return true;
}
// Native Windows: assume ConHost has VT processing enabled. Pre-1709
// hosts are effectively extinct and would gracefully degrade by
// ignoring the SGR 38;2;... sequences.
cfg!(target_os = "windows")
}
// ── 256 → 16 mapping ────────────────────────────────────────────────────
/// Map a 256-color index to the nearest basic ANSI 16 color.
fn indexed_to_ansi16(n: u8) -> Color {
match n {
// First 16 indices already *are* the ANSI 16 colors.
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::White, // actually "silver" in most terminals
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
15 => Color::White,
// For 16255, convert to RGB and find nearest ANSI 16 color.
_ => {
let (r, g, b) = indexed_to_rgb(n);
rgb_to_ansi16(r, g, b)
}
}
}
/// Find the nearest ANSI 16 color for an RGB triplet.
///
/// Uses a simple squared-Euclidean distance over the standard xterm ANSI 16
/// palette. Good enough for a fallback — 16-color terminals are very rare.
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
// Standard xterm ANSI 16 palette (same values used by indexed_to_rgb for 015).
const PALETTE: [(u8, u8, u8, Color); 16] = [
(0, 0, 0, Color::Black),
(128, 0, 0, Color::Red),
(0, 128, 0, Color::Green),
(128, 128, 0, Color::Yellow),
(0, 0, 128, Color::Blue),
(128, 0, 128, Color::Magenta),
(0, 128, 128, Color::Cyan),
(192, 192, 192, Color::White),
(128, 128, 128, Color::DarkGray),
(255, 0, 0, Color::LightRed),
(0, 255, 0, Color::LightGreen),
(255, 255, 0, Color::LightYellow),
(0, 0, 255, Color::LightBlue),
(255, 0, 255, Color::LightMagenta),
(0, 255, 255, Color::LightCyan),
(255, 255, 255, Color::White), // index 15 = bright white
];
let mut best = Color::White;
let mut best_dist = u32::MAX;
for &(pr, pg, pb, color) in &PALETTE {
let dr = r as i32 - pr as i32;
let dg = g as i32 - pg as i32;
let db = b as i32 - pb as i32;
let dist = (dr * dr + dg * dg + db * db) as u32;
if dist < best_dist {
best_dist = dist;
best = color;
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truecolor_passes_through() {
let rgb = Color::Rgb(122, 162, 247);
assert_eq!(quantize_color(rgb, ColorLevel::TrueColor), rgb);
let idx = Color::Indexed(141);
assert_eq!(quantize_color(idx, ColorLevel::TrueColor), idx);
}
#[test]
fn ansi256_quantizes_rgb_to_indexed() {
let rgb = Color::Rgb(122, 162, 247);
let q = quantize_color(rgb, ColorLevel::Ansi256);
assert!(matches!(q, Color::Indexed(_)));
}
#[test]
fn ansi256_passes_indexed_through() {
let idx = Color::Indexed(141);
assert_eq!(quantize_color(idx, ColorLevel::Ansi256), idx);
}
#[test]
fn basic_quantizes_to_named() {
let rgb = Color::Rgb(255, 0, 0);
let q = quantize_color(rgb, ColorLevel::Basic);
// Should map to a red variant
assert!(
matches!(q, Color::Red | Color::LightRed),
"expected Red/LightRed, got {q:?}"
);
}
#[test]
fn basic_quantizes_indexed_to_named() {
// Indexed(196) = (255,0,0) — pure bright red in the cube
let idx = Color::Indexed(196);
let q = quantize_color(idx, ColorLevel::Basic);
assert!(
matches!(q, Color::Red | Color::LightRed),
"expected Red/LightRed, got {q:?}"
);
}
#[test]
fn none_resets_everything() {
assert_eq!(
quantize_color(Color::Rgb(100, 200, 50), ColorLevel::None),
Color::Reset
);
assert_eq!(
quantize_color(Color::Indexed(111), ColorLevel::None),
Color::Reset
);
}
#[test]
fn named_colors_pass_through_all_levels() {
for level in [
ColorLevel::TrueColor,
ColorLevel::Ansi256,
ColorLevel::Basic,
] {
assert_eq!(quantize_color(Color::Red, level), Color::Red);
assert_eq!(quantize_color(Color::Blue, level), Color::Blue);
}
}
#[test]
fn level_ordering() {
assert!(ColorLevel::None < ColorLevel::Basic);
assert!(ColorLevel::Basic < ColorLevel::Ansi256);
assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor);
}
#[test]
fn ansi16_roundtrip_first_16() {
// Indices 015 should map to their corresponding named colors
assert_eq!(indexed_to_ansi16(0), Color::Black);
assert_eq!(indexed_to_ansi16(1), Color::Red);
assert_eq!(indexed_to_ansi16(4), Color::Blue);
assert_eq!(indexed_to_ansi16(9), Color::LightRed);
assert_eq!(indexed_to_ansi16(14), Color::LightCyan);
}
}
@@ -0,0 +1,140 @@
//! GrokDay theme — neutral gray base (light) with deepened accent colors.
//!
//! Light counterpart to GrokNight. Backgrounds and text use a neutral
//! grayscale ramp (no blue/warm tint). Accent colors are the same hue
//! family as GrokNight but deepened for contrast on light backgrounds.
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
#[allow(dead_code)]
mod palette {
use super::*;
// ── Backgrounds (neutral light grays) ────────────────────────────────
pub const BG: Color = rgb(245, 245, 245); // #f5f5f5 — brightest (terminal bg)
pub const BG_DARK: Color = rgb(240, 240, 240); // #f0f0f0
pub const BG_STORM_DARK: Color = rgb(234, 234, 234); // #eaeaea
pub const BG_STORM: Color = rgb(238, 238, 238); // #eeeeee — main bg
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222); // #dedede — highlight bg
// ── Text / grays (neutral dark) ──────────────────────────────────────
pub const FG: Color = rgb(38, 38, 38); // #262626 — primary text
pub const FG_DARK: Color = rgb(68, 68, 68); // #444444 — secondary text
pub const FG_GUTTER: Color = rgb(178, 178, 178); // #b2b2b2 — dim
pub const COMMENT: Color = rgb(118, 118, 118); // #767676 — muted
pub const DARK3: Color = rgb(142, 142, 142); // #8e8e8e — medium gray
pub const DARK5: Color = rgb(98, 98, 98); // #626262 — bright gray
// ── Accent colors (deepened for light-background contrast) ───────────
pub const BLUE: Color = rgb(47, 100, 210); // #2F64D2
pub const BLUE0: Color = rgb(40, 68, 138); // #28448A
pub const BLUE1: Color = rgb(15, 135, 162); // #0F87A2
pub const CYAN: Color = rgb(0, 130, 170); // #0082AA
pub const GREEN: Color = rgb(55, 142, 35); // #378E23
pub const GREEN1: Color = rgb(12, 148, 124); // #0C947C
pub const MAGENTA: Color = rgb(125, 75, 198); // #7D4BC6
pub const ORANGE: Color = rgb(195, 105, 30); // #C3691E
pub const PURPLE: Color = rgb(108, 62, 178); // #6C3EB2
pub const RED: Color = rgb(205, 48, 72); // #CD3048
pub const RED1: Color = rgb(175, 35, 35); // #AF2323
pub const TEAL: Color = rgb(10, 142, 112); // #0A8E70
pub const YELLOW: Color = rgb(162, 118, 18); // #A27612
pub const RED_LIGHT: Color = rgb(245, 218, 222); // #F5DADE — diff delete bg
pub const GREEN_LIGHT: Color = rgb(218, 242, 220); // #DAF2DC — diff insert bg
}
use palette::*;
impl Theme {
pub const fn grokday() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: rgb(228, 228, 228),
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(208, 208, 208),
bg_terminal: BG,
accent_user: FG_DARK,
accent_assistant: MAGENTA,
accent_thinking: MAGENTA,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: BLUE,
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(165, 165, 165), // #a5a5a5 — slightly darker than FG_GUTTER
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(168, 120, 10), // #A8780A — deep golden
accent_verify: rgb(120, 80, 160), // deep violet (readable on light bg)
accent_feedback: GREEN1,
accent_remember: rgb(76, 175, 80), // #4CAF50 — Material Design green (readable on light bg)
selection_border: rgb(185, 185, 190),
prompt_border: rgb(200, 200, 205), // #C8C8CD — dimmer prompt chrome
prompt_border_active: rgb(165, 165, 175), // #A5A5AF — darker (more apparent) when focused
hover_border: rgb(212, 212, 216),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: RED_LIGHT,
diff_delete_fg: RED,
diff_insert_bg: GREEN_LIGHT,
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(198, 198, 198),
paste_bg: BG_HIGHLIGHT,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: DARK5,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: COMMENT,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: DARK3,
md_heading_h6_mod: Modifier::empty(),
md_code: BLUE1,
md_task_checked: GREEN,
md_task_unchecked: FG_DARK,
md_muted: COMMENT,
md_code_bg: rgb(228, 228, 228),
md_text: FG_DARK,
link_fg: BLUE, // #2F64D2 -- deep blue for light bg
}
}
}
@@ -0,0 +1,166 @@
//! GrokNight theme — neutral gray base with TokyoNight accent colors.
//!
//! The canonical palette is defined in RGB (`Color::Rgb`). At startup the
//! theme is run through [`Theme::quantized`] which downgrades every color
//! to the terminal's detected capability level (256-color, 16-color, etc.).
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
/// Helper for concise const `Color::Rgb` definitions.
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
// GrokNight palette — neutral gray base + TokyoNight accent colors.
//
// Backgrounds and text use a custom grayscale ramp anchored at:
// • bg = #141414 (20)
// • fg = #f3f3f3 (243)
//
// Accent colors are the original TokyoNight Night hex values.
#[allow(dead_code)]
mod palette {
use super::*;
// ── Backgrounds ─────────────────────────────────────────────────────
pub const BG: Color = rgb(10, 10, 10); // #0a0a0a — Night (terminal bg)
pub const BG_DARK: Color = rgb(12, 12, 12); // #0c0c0c — darkest
pub const BG_STORM_DARK: Color = rgb(17, 17, 17); // #111111 — dark bg
pub const BG_STORM: Color = rgb(20, 20, 20); // #141414 — main bg
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36); // #242424 — highlight bg
// ── Text / grays ────────────────────────────────────────────────────
pub const FG: Color = rgb(225, 225, 225); // #e1e1e1 — primary text
pub const FG_DARK: Color = rgb(200, 200, 200); // #c8c8c8 — secondary text
pub const FG_GUTTER: Color = rgb(65, 65, 65); // #414141 — dim
pub const COMMENT: Color = rgb(108, 108, 108); // #6c6c6c — muted
pub const DARK3: Color = rgb(90, 90, 90); // #5a5a5a — medium gray
pub const DARK5: Color = rgb(120, 120, 120); // #787878 — bright gray
// ── Accent colors (TokyoNight Night) ─────────────────────────────────
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
pub const BLUE1: Color = rgb(58, 149, 171); // #3A95AB
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
pub const RED: Color = rgb(247, 118, 142); // #f7768e
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
pub const RED_DARK: Color = rgb(66, 14, 20); // #420e14 — quantizes to 256-color red, not gray
pub const GREEN_DARK: Color = rgb(6, 56, 6); // #063806 — quantizes to 256-color green, not gray
}
use palette::*;
impl Theme {
/// GrokNight theme — neutral gray base with TokyoNight accents.
///
/// Colors are defined in RGB. Call [`Theme::quantized`] to downgrade
/// them to the terminal's supported color level before rendering.
pub const fn groknight() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: rgb(28, 28, 28), // lighter than bg_base for visible code blocks
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(44, 44, 44),
bg_terminal: BG,
accent_user: FG_DARK,
accent_assistant: MAGENTA,
accent_thinking: MAGENTA,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: BLUE,
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(88, 88, 88), // #585858 — slightly brighter than FG_GUTTER
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(255, 219, 141), // #FFDB8D — golden
accent_verify: rgb(187, 154, 247), // #bb9af7 — violet
accent_feedback: GREEN1, // #73daca
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
selection_border: rgb(60, 60, 65),
prompt_border: rgb(50, 50, 55), // #323237 — dimmer prompt chrome
prompt_border_active: rgb(80, 80, 88), // #505058 — brighter when focused
hover_border: rgb(30, 30, 34),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: RED_DARK,
diff_delete_fg: RED,
diff_insert_bg: GREEN_DARK,
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(54, 54, 54),
paste_bg: BG_STORM_DARK,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: DARK5, // bright gray
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: COMMENT, // medium gray
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: DARK3, // medium gray, unbold
md_heading_h6_mod: Modifier::empty(),
md_code: BLUE1,
md_task_checked: GREEN,
md_task_unchecked: FG_DARK, // text_secondary
md_muted: COMMENT,
md_code_bg: rgb(28, 28, 28),
md_text: FG_DARK,
link_fg: rgb(122, 166, 218), // #7aa6da -- soft blue for dark bg
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "known broken: expected accent values drift from runtime theme"]
fn test_groknight_theme() {
let theme = Theme::groknight();
assert!(matches!(theme.bg_base, Color::Rgb(20, 20, 20)));
assert!(matches!(theme.accent_user, Color::Rgb(225, 225, 225)));
assert!(matches!(theme.text_primary, Color::Rgb(225, 225, 225)));
}
}
@@ -0,0 +1,198 @@
//! Theme-aware markdown rendering style.
//!
//! Defines the `MarkdownStyle` used by agent message and thinking blocks.
//! Colors come from the `md_*` fields on the current [`Theme`], which are
//! already quantized to the terminal's color capability level.
use anstyle::{Ansi256Color, AnsiColor, Color, Style};
use kigi_markdown::MarkdownStyle;
/// Convert `ratatui::style::Color` → `anstyle::Color` (type conversion only).
///
/// Quantization is already handled by [`Theme::current()`], so this just
/// bridges the two color types.
///
/// Returns `None` for `Reset`: `anstyle::Color` has no "terminal default"
/// variant, and downstream an unset color renders as the terminal default.
fn to_anstyle(c: ratatui::style::Color) -> Option<Color> {
Some(match c {
ratatui::style::Color::Reset => return None,
ratatui::style::Color::Rgb(r, g, b) => Color::Rgb(anstyle::RgbColor(r, g, b)),
ratatui::style::Color::Indexed(n) => Color::Ansi256(Ansi256Color(n)),
// Named ANSI colors (from 16-color quantization).
ratatui::style::Color::Black => Color::Ansi(AnsiColor::Black),
ratatui::style::Color::Red => Color::Ansi(AnsiColor::Red),
ratatui::style::Color::Green => Color::Ansi(AnsiColor::Green),
ratatui::style::Color::Yellow => Color::Ansi(AnsiColor::Yellow),
ratatui::style::Color::Blue => Color::Ansi(AnsiColor::Blue),
ratatui::style::Color::Magenta => Color::Ansi(AnsiColor::Magenta),
ratatui::style::Color::Cyan => Color::Ansi(AnsiColor::Cyan),
ratatui::style::Color::Gray => Color::Ansi(AnsiColor::White),
ratatui::style::Color::DarkGray => Color::Ansi(AnsiColor::BrightBlack),
ratatui::style::Color::LightRed => Color::Ansi(AnsiColor::BrightRed),
ratatui::style::Color::LightGreen => Color::Ansi(AnsiColor::BrightGreen),
ratatui::style::Color::LightYellow => Color::Ansi(AnsiColor::BrightYellow),
ratatui::style::Color::LightBlue => Color::Ansi(AnsiColor::BrightBlue),
ratatui::style::Color::LightMagenta => Color::Ansi(AnsiColor::BrightMagenta),
ratatui::style::Color::LightCyan => Color::Ansi(AnsiColor::BrightCyan),
ratatui::style::Color::White => Color::Ansi(AnsiColor::BrightWhite),
})
}
/// `anstyle::Style` with the given foreground color (converted from ratatui).
fn fg(c: ratatui::style::Color) -> Style {
Style::new().fg_color(to_anstyle(c))
}
/// `anstyle::Style` with the given background color (converted from ratatui).
fn bg(c: ratatui::style::Color) -> Style {
Style::new().bg_color(to_anstyle(c))
}
/// Convert `ratatui::style::Modifier` flags to `anstyle::Style` effects.
fn modifier_to_anstyle(m: ratatui::style::Modifier) -> Style {
let mut s = Style::new();
if m.contains(ratatui::style::Modifier::BOLD) {
s = s.bold();
}
if m.contains(ratatui::style::Modifier::ITALIC) {
s = s.italic();
}
if m.contains(ratatui::style::Modifier::UNDERLINED) {
s = s.underline();
}
if m.contains(ratatui::style::Modifier::DIM) {
s = s.dimmed();
}
if m.contains(ratatui::style::Modifier::HIDDEN) {
s = s.hidden();
}
if m.contains(ratatui::style::Modifier::CROSSED_OUT) {
s = s.strikethrough();
}
s
}
/// Build heading inner styles from theme colors and per-level modifiers.
fn heading_inner_styles(
colors: [ratatui::style::Color; 6],
mods: [ratatui::style::Modifier; 6],
) -> [Style; 6] {
std::array::from_fn(|i| {
let color_style = fg(colors[i]);
let mod_style = modifier_to_anstyle(mods[i]);
// Combine fg color with modifier effects.
let mut s = color_style;
let effects = mod_style.get_effects();
if !effects.is_plain() {
s = s.effects(s.get_effects() | effects);
}
s
})
}
/// Build heading outer styles (dimmed + hidden, for syntax markers).
fn heading_outer_styles(colors: [ratatui::style::Color; 6]) -> [Style; 6] {
colors.map(|c| fg(c).dimmed().hidden())
}
/// Get the theme-aware markdown style.
///
/// Built fresh from [`Theme::current()`] on each call. Both the theme
/// construction and style mapping are trivial struct copies.
pub fn style() -> MarkdownStyle {
build_style()
}
fn build_style() -> MarkdownStyle {
let theme = super::Theme::current();
let heading_colors = [
theme.md_heading_h1,
theme.md_heading_h2,
theme.md_heading_h3,
theme.md_heading_h4,
theme.md_heading_h5,
theme.md_heading_h6,
];
let heading_mods = [
theme.md_heading_h1_mod,
theme.md_heading_h2_mod,
theme.md_heading_h3_mod,
theme.md_heading_h4_mod,
theme.md_heading_h5_mod,
theme.md_heading_h6_mod,
];
MarkdownStyle {
heading_inner: heading_inner_styles(heading_colors, heading_mods),
heading_outer: heading_outer_styles(heading_colors),
strong_inner: fg(theme.md_text).bold(),
strong_outer: Style::new().dimmed().hidden(),
emphasis_inner: fg(theme.md_text).italic(),
emphasis_outer: Style::new().dimmed().hidden(),
strikethrough_inner: fg(theme.md_text).strikethrough(),
strikethrough_outer: Style::new().dimmed().hidden(),
inline_code_inner: fg(theme.md_code).bold(),
inline_code_outer: fg(theme.md_code).dimmed().hidden(),
// Selection-side bar detection (kigi-tui scrollback/blocks/
// quote_bar.rs quote_bar_style) mirrors this exact style; its
// end-to-end tests fail if this line changes.
blockquote_outer: fg(theme.md_muted).dimmed(),
task_checked: fg(theme.md_task_checked),
task_unchecked: fg(theme.md_task_unchecked).dimmed(),
list_item: fg(theme.md_muted),
rule: fg(theme.md_muted),
link_outer: fg(theme.md_muted),
link_text: fg(theme.link_fg).underline(),
link_url: fg(theme.md_muted),
link_title: fg(theme.md_heading_h5),
code_outer: fg(theme.md_code).dimmed().hidden(),
code_language: fg(theme.md_heading_h3).hidden(),
code_untagged: fg(theme.md_text),
code_background: bg(theme.md_code_bg),
table_outer: fg(theme.md_heading_h2).hidden(),
text: fg(theme.md_text),
math: fg(theme.md_text).italic(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression: `Reset` used to fall back to a concrete `AnsiColor::White`
/// (ANSI-7 silver), which rendered Reset-themed markdown washed-out gray
/// on light terminals and broke the `NO_COLOR` opt-out.
#[test]
fn reset_maps_to_no_color() {
assert_eq!(to_anstyle(ratatui::style::Color::Reset), None);
assert_eq!(fg(ratatui::style::Color::Reset).get_fg_color(), None);
assert_eq!(bg(ratatui::style::Color::Reset).get_bg_color(), None);
}
/// Spot checks around the Gray/DarkGray naming mismatch between ratatui
/// and anstyle.
#[test]
fn named_colors_map_concretely() {
assert_eq!(
to_anstyle(ratatui::style::Color::DarkGray),
Some(Color::Ansi(AnsiColor::BrightBlack))
);
assert_eq!(
to_anstyle(ratatui::style::Color::Gray),
Some(Color::Ansi(AnsiColor::White))
);
assert_eq!(
to_anstyle(ratatui::style::Color::Red),
Some(Color::Ansi(AnsiColor::Red))
);
}
#[test]
fn terminal_default_body_text_has_no_fg() {
let theme = super::super::Theme::terminal_default();
assert_eq!(fg(theme.md_text).get_fg_color(), None);
assert_eq!(bg(theme.md_code_bg).get_bg_color(), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,466 @@
//! OSC 11 terminal background detection.
//!
//! Queries the terminal's background color via the OSC 11 escape sequence:
//! Query: `\x1b]11;?\x07`
//! Reply: `\x1b]11;rgb:RRRR/GGGG/BBBB\x07` (or ST terminator `\x1b\\`)
//!
//! The response contains hex color values (2-digit or 4-digit per channel).
//! For 4-digit values we extract the high byte; for 2-digit we use the value
//! directly. Relative luminance (ITU-R BT.709) classifies the background as
//! dark or light.
//!
//! This is a **startup-only** fallback — it must NOT be called once
//! crossterm's `EventStream` is active, as both compete for stdin in raw
//! mode. The live `SystemAppearanceWatcher` uses only
//! `dark-light::detect()`.
use super::system_appearance::SystemAppearance;
use std::time::Duration;
#[cfg(unix)]
use std::os::unix::io::RawFd;
/// Luminance threshold: backgrounds with Y < 0.5 are considered dark.
const LUMINANCE_THRESHOLD: f64 = 0.5;
/// Timeout for reading the OSC 11 response from the terminal.
const OSC11_TIMEOUT: Duration = Duration::from_millis(500);
/// Detect system appearance by querying the terminal's background color.
///
/// Returns `None` if stdin is not a TTY, the terminal does not respond
/// within `OSC11_TIMEOUT`, or the response cannot be parsed.
///
/// MUST be called before crossterm's event stream is initialized.
/// Manages stdin termios locally (no `crossterm::enable_raw_mode`) and
/// routes the query write through the shared stderr lock to avoid
/// interleaving with the render writer thread.
pub fn detect_via_osc11() -> Option<SystemAppearance> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return None;
}
if !crate::terminal::probe::write_query(b"\x1b]11;?\x07") {
return None;
}
let response = read_osc_response(OSC11_TIMEOUT)?;
let (r, g, b) = parse_osc11_rgb(&response)?;
Some(classify_luminance(r, g, b))
}
/// Classify an sRGB color as dark or light based on relative luminance.
///
/// Uses ITU-R BT.709 luminance coefficients with sRGB gamma correction.
/// Threshold at 0.5 — below is dark, at or above is light.
pub(crate) fn classify_luminance(r: u8, g: u8, b: u8) -> SystemAppearance {
let luminance =
0.2126 * srgb_to_linear(r) + 0.7152 * srgb_to_linear(g) + 0.0722 * srgb_to_linear(b);
if luminance < LUMINANCE_THRESHOLD {
SystemAppearance::Dark
} else {
SystemAppearance::Light
}
}
/// Parse the RGB components from an OSC 11 response string.
///
/// Handles both 4-digit (`rgb:RRRR/GGGG/BBBB`) and 2-digit (`rgb:RR/GG/BB`)
/// hex formats. For 4-digit values the high byte is extracted (>> 8).
pub(crate) fn parse_osc11_rgb(response: &str) -> Option<(u8, u8, u8)> {
let rgb_start = response.find("rgb:")? + 4;
let rgb_part = &response[rgb_start..];
// Split on channel separator `/` and terminators (BEL, ESC).
let parts: Vec<&str> = rgb_part.split(['/', '\x07', '\x1b']).take(3).collect();
if parts.len() < 3 {
return None;
}
Some((
parse_channel(parts[0])?,
parse_channel(parts[1])?,
parse_channel(parts[2])?,
))
}
/// Parse a single hex color channel.
///
/// For 34 digit values, extracts the high byte (`>> 8`) to map to 0255.
/// For 12 digit values, uses the value directly as 0255.
fn parse_channel(s: &str) -> Option<u8> {
let trimmed = s.trim();
let val = u16::from_str_radix(trimmed, 16).ok()?;
Some(if trimmed.len() > 2 {
(val >> 8) as u8
} else {
val as u8
})
}
/// Convert an sRGB channel value (0255) to linear light.
///
/// Applies the sRGB transfer function inverse (IEC 61966-2-1).
fn srgb_to_linear(c: u8) -> f64 {
let s = c as f64 / 255.0;
if s <= 0.04045 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
/// Restores the original termios on drop without touching crossterm's
/// process-wide `TERMINAL_MODE_PRIOR_RAW_MODE`. Calling
/// `crossterm::disable_raw_mode` here would restore the shell's
/// pre-pager cooked termios, breaking the pager's own raw mode.
#[cfg(unix)]
struct TermiosGuard {
fd: RawFd,
original: libc::termios,
}
#[cfg(unix)]
impl Drop for TermiosGuard {
fn drop(&mut self) {
// SAFETY: fd was valid at construction; original was populated
// by a successful tcgetattr.
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
/// POSIX-portable subset of `cfmakeraw(3)`: clear the lflags that would
/// block a single-byte read (canonical mode, echo, signal interpretation,
/// extended processing).
#[cfg(unix)]
fn make_raw_termios(snapshot: &libc::termios) -> libc::termios {
let mut raw = *snapshot;
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
raw
}
#[cfg(unix)]
fn read_osc_response(timeout: Duration) -> Option<String> {
use std::os::unix::io::AsRawFd;
read_osc_response_with_fd(std::io::stdin().as_raw_fd(), timeout)
}
#[cfg(not(unix))]
fn read_osc_response(_timeout: Duration) -> Option<String> {
None
}
/// `fd`-parameterized for tests (pass `/dev/null` to exercise the
/// non-TTY path). Guard is constructed before `tcsetattr` to keep the
/// restore atomic with the switch -- POSIX guarantees `tcsetattr` is
/// atomic on failure, so a redundant restore on the early-return path
/// is harmless.
#[cfg(unix)]
fn read_osc_response_with_fd(fd: RawFd, timeout: Duration) -> Option<String> {
let mut original: libc::termios = unsafe { std::mem::zeroed() };
// SAFETY: caller passes a valid fd; original is a valid owned buffer.
if unsafe { libc::tcgetattr(fd, &mut original) } != 0 {
return None;
}
let raw = make_raw_termios(&original);
let _guard = TermiosGuard { fd, original };
// SAFETY: raw is a valid owned buffer.
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
return None;
}
read_with_timeout(timeout)
}
/// Read bytes from stdin until a terminator is found or timeout expires.
///
/// Recognizes two terminators:
/// - BEL (`\x07`)
/// - ST (`\x1b\x5c`, i.e. ESC + backslash)
///
/// Uses `libc::poll` + `libc::read` for non-blocking reads with a timeout
/// on Unix. Returns `None` on non-Unix platforms.
// Only invoked from `read_osc_response_with_fd`, which is Unix-only.
#[cfg(unix)]
fn read_with_timeout(timeout: Duration) -> Option<String> {
unix_read_with_timeout(timeout)
}
/// Unix implementation: shared probe read loop with the OSC terminators
/// (BEL, or ST as `ESC \`) as the stop predicate.
#[cfg(unix)]
fn unix_read_with_timeout(timeout: Duration) -> Option<String> {
let buf = crate::terminal::probe::read_tty_reply(timeout, |buf, byte| {
byte == 0x07 || (buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && byte == 0x5c)
})?;
// Reject partial buffers: a reply truncated mid-channel would
// mis-parse, since channel width is inferred from digit count.
if !ends_with_osc_terminator(&buf) {
return None;
}
String::from_utf8(buf).ok()
}
/// True when the buffer ends with BEL or ST (`ESC \`).
#[cfg(any(unix, test))]
fn ends_with_osc_terminator(buf: &[u8]) -> bool {
buf.last() == Some(&0x07) || buf.ends_with(b"\x1b\\")
}
#[cfg(test)]
mod tests {
use super::*;
// -- ends_with_osc_terminator ---------------------------------------------
#[test]
fn unterminated_reply_is_rejected() {
// A truncated channel would mis-parse and could flip dark/light.
assert!(!ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/00"));
assert!(ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/ffff\x07"));
assert!(ends_with_osc_terminator(
b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\"
));
assert!(!ends_with_osc_terminator(b""));
}
// -- parse_osc11_rgb -----------------------------------------------------
#[test]
fn parse_4digit_white() {
// xterm-style: rgb:ffff/ffff/ffff
let response = "\x1b]11;rgb:ffff/ffff/ffff\x07";
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
}
#[test]
fn parse_4digit_black() {
let response = "\x1b]11;rgb:0000/0000/0000\x07";
assert_eq!(parse_osc11_rgb(response), Some((0, 0, 0)));
}
#[test]
fn parse_2digit_dark() {
// Some terminals use 2-digit hex: rgb:1a/1b/26
let response = "\x1b]11;rgb:1a/1b/26\x07";
assert_eq!(parse_osc11_rgb(response), Some((0x1a, 0x1b, 0x26)));
}
#[test]
fn parse_2digit_light() {
let response = "\x1b]11;rgb:f0/f0/f0\x07";
assert_eq!(parse_osc11_rgb(response), Some((0xf0, 0xf0, 0xf0)));
}
#[test]
fn parse_4digit_midrange() {
// rgb:8080/8080/8080 → high byte is 0x80 = 128
let response = "\x1b]11;rgb:8080/8080/8080\x07";
assert_eq!(parse_osc11_rgb(response), Some((128, 128, 128)));
}
#[test]
fn parse_st_terminator() {
// Some terminals use ESC \ (ST) instead of BEL as terminator.
let response = "\x1b]11;rgb:ffff/ffff/ffff\x1b\\";
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
}
#[test]
fn parse_missing_rgb_prefix() {
let response = "\x1b]11;color:ffff/ffff/ffff\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_too_few_channels() {
let response = "\x1b]11;rgb:ffff/ffff\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_empty_response() {
assert!(parse_osc11_rgb("").is_none());
}
#[test]
fn parse_invalid_hex() {
let response = "\x1b]11;rgb:gggg/hhhh/iiii\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_1digit_channel() {
// Edge case: single digit per channel (treated as 2-digit path).
let response = "\x1b]11;rgb:f/f/f\x07";
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
}
#[test]
fn parse_3digit_channel() {
// 3-digit hex (uncommon but possible) — >2 digits, so high byte extracted.
// 0xfff = 4095, >> 8 = 15
let response = "\x1b]11;rgb:fff/fff/fff\x07";
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
}
// -- parse_channel -------------------------------------------------------
#[test]
fn channel_4digit_max() {
assert_eq!(parse_channel("ffff"), Some(255));
}
#[test]
fn channel_4digit_zero() {
assert_eq!(parse_channel("0000"), Some(0));
}
#[test]
fn channel_2digit_max() {
assert_eq!(parse_channel("ff"), Some(255));
}
#[test]
fn channel_2digit_zero() {
assert_eq!(parse_channel("00"), Some(0));
}
#[test]
fn channel_with_whitespace() {
assert_eq!(parse_channel(" ff "), Some(255));
}
// -- classify_luminance --------------------------------------------------
#[test]
fn classify_pure_black_is_dark() {
assert_eq!(classify_luminance(0, 0, 0), SystemAppearance::Dark);
}
#[test]
fn classify_pure_white_is_light() {
assert_eq!(classify_luminance(255, 255, 255), SystemAppearance::Light);
}
#[test]
fn classify_dark_gray_is_dark() {
// Typical dark terminal background: #1a1b26 (TokyoNight)
assert_eq!(classify_luminance(0x1a, 0x1b, 0x26), SystemAppearance::Dark);
}
#[test]
fn classify_light_gray_is_light() {
// Typical light terminal background: #f0f0f0
assert_eq!(
classify_luminance(0xf0, 0xf0, 0xf0),
SystemAppearance::Light
);
}
#[test]
fn classify_mid_gray_boundary() {
// sRGB (186, 186, 186) has luminance ≈ 0.497 → just below 0.5 → Dark
// sRGB (188, 188, 188) has luminance ≈ 0.508 → just above 0.5 → Light
assert_eq!(classify_luminance(186, 186, 186), SystemAppearance::Dark);
assert_eq!(classify_luminance(188, 188, 188), SystemAppearance::Light);
}
#[test]
fn classify_solarized_dark_is_dark() {
// Solarized Dark base03: #002b36
assert_eq!(classify_luminance(0x00, 0x2b, 0x36), SystemAppearance::Dark);
}
#[test]
fn classify_solarized_light_is_light() {
// Solarized Light base3: #fdf6e3
assert_eq!(
classify_luminance(0xfd, 0xf6, 0xe3),
SystemAppearance::Light
);
}
// -- srgb_to_linear ------------------------------------------------------
#[test]
fn srgb_to_linear_zero() {
assert!((srgb_to_linear(0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn srgb_to_linear_max() {
assert!((srgb_to_linear(255) - 1.0).abs() < 1e-10);
}
#[test]
fn srgb_to_linear_low_value() {
// 10/255 ≈ 0.0392 < 0.04045 → linear branch
let result = srgb_to_linear(10);
let expected = (10.0 / 255.0) / 12.92;
assert!((result - expected).abs() < 1e-10);
}
#[test]
fn srgb_to_linear_high_value() {
// 128/255 ≈ 0.502 > 0.04045 → gamma branch
let result = srgb_to_linear(128);
let s: f64 = 128.0 / 255.0;
let expected = ((s + 0.055) / 1.055).powf(2.4);
assert!((result - expected).abs() < 1e-10);
}
// -- detect_via_osc11 (graceful degradation) -----------------------------
#[test]
fn detect_returns_none_when_not_tty() {
// In CI / test runners stdin is captured; the early `is_terminal`
// check must return None without writing anything to stderr.
assert_eq!(detect_via_osc11(), None);
}
#[cfg(unix)]
#[test]
fn read_osc_response_with_fd_returns_none_for_non_tty_fd() {
// tcgetattr on /dev/null returns ENOTTY; we must bail without
// panicking and without touching crossterm's process-wide state.
use std::os::unix::io::AsRawFd;
let f = std::fs::File::open("/dev/null").unwrap();
let result = read_osc_response_with_fd(f.as_raw_fd(), Duration::from_millis(10));
assert_eq!(result, None);
}
#[cfg(unix)]
#[test]
fn make_raw_termios_clears_only_canonical_echo_signal_extended() {
// Pre-populate with cleared bits AND preserved bits, then assert
// the result is exactly the preserved set. Catches regressions
// that widen the mask.
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
snapshot.c_lflag =
libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN | libc::TOSTOP | libc::NOFLSH;
let raw = make_raw_termios(&snapshot);
assert_eq!(raw.c_lflag, libc::TOSTOP | libc::NOFLSH);
}
#[cfg(unix)]
#[test]
fn make_raw_termios_preserves_other_flag_words() {
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
snapshot.c_lflag = libc::TOSTOP | libc::ICANON;
snapshot.c_iflag = libc::ICRNL;
snapshot.c_oflag = libc::OPOST;
snapshot.c_cflag = libc::CS8;
let raw = make_raw_termios(&snapshot);
assert_eq!(raw.c_lflag & libc::TOSTOP, libc::TOSTOP);
assert_eq!(raw.c_iflag, snapshot.c_iflag);
assert_eq!(raw.c_oflag, snapshot.c_oflag);
assert_eq!(raw.c_cflag, snapshot.c_cflag);
}
}
@@ -0,0 +1,149 @@
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
/// Oscura Midnight palette.
///
/// Deep, dark backgrounds with a subtle purple/blue tint (OKLCH hue 265),
/// inspired by the Oscura Midnight palette (narative/oscura). Accent colors
/// lean purple to give the theme its distinctive identity.
///
/// Base colors were converted from OKLCH to sRGB programmatically via
/// the `coloraide` Python library. Purple accent colors are hand-picked
/// to complement the hue-265 background tint.
#[allow(dead_code)]
mod palette {
use super::*;
// -- backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265) -------
pub const BASE: Color = rgb(3, 3, 4); // #030304 oklch(0.1 0.005 265)
pub const SURFACE: Color = rgb(4, 5, 7); // #040507 oklch(0.115 0.005 265)
pub const ELEVATED: Color = rgb(15, 18, 22); // #0F1216 oklch(0.18 0.01 265)
pub const PANEL: Color = rgb(4, 4, 6); // #040406 oklch(0.11 0.006 265)
// -- text (neutral, no color cast) ----------------------------------------
pub const TEXT: Color = rgb(228, 228, 228); // #E4E4E4 oklch(0.92 0 0)
pub const TEXT_DIM: Color = rgb(190, 190, 190); // #BEBEBE oklch(0.8 0 0)
// -- muted text (slight blue-purple tint) ---------------------------------
pub const MUTED: Color = rgb(129, 134, 143); // #81868F oklch(0.62 0.015 260)
pub const SUBTLE: Color = rgb(94, 100, 108); // #5E646C oklch(0.5 0.015 260)
// -- semantic colors (from desktop action tokens) -------------------------
pub const GOLD: Color = rgb(235, 217, 110); // #EBD96E oklch(0.88 0.13 100)
pub const RED: Color = rgb(220, 90, 100); // #DC5A64 muted rose-red
pub const TEAL: Color = rgb(80, 180, 140); // #50B48C softened teal
pub const AMBER: Color = rgb(241, 189, 0); // #F1BD00 oklch(0.82 0.18 90)
// -- purple accent ramp (the "purple hints") ------------------------------
pub const PURPLE: Color = rgb(155, 126, 206); // #9B7ECE — signature purple
pub const PURPLE_DIM: Color = rgb(110, 90, 154); // #6E5A9A — muted purple
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231); // #C4A7E7 — vivid lavender
// -- cyan (for running indicators, links) ---------------------------------
pub const CYAN: Color = rgb(125, 207, 223); // #7DCFDF
// -- highlight ramp (purple-tinted grays for UI chrome) -------------------
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28); // #12101C
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52); // #242034
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72); // #343048
}
use palette::*;
impl Theme {
pub const fn oscura_midnight() -> Self {
Self {
bg_base: BASE,
bg_light: ELEVATED,
bg_dark: SURFACE,
bg_highlight: ELEVATED,
bg_hover: HIGHLIGHT_MED,
bg_terminal: BASE,
accent_user: PURPLE_BRIGHT,
accent_assistant: PURPLE,
accent_thinking: MUTED,
accent_tool: SUBTLE,
accent_system: CYAN,
accent_error: RED,
accent_success: TEAL,
accent_running: PURPLE_DIM,
accent_skill: PURPLE,
text_primary: TEXT,
text_secondary: TEXT_DIM,
gray_dim: SUBTLE,
gray: MUTED,
gray_bright: TEXT_DIM,
command: GOLD,
path: AMBER,
running: CYAN,
warning: GOLD,
fuzzy_accent: PURPLE_BRIGHT,
accent_plan: GOLD,
accent_verify: PURPLE,
accent_feedback: TEAL,
accent_remember: rgb(139, 195, 74), // #8BC34A — Material Design light green
selection_border: HIGHLIGHT_HIGH,
hover_border: HIGHLIGHT_MED,
prompt_border: HIGHLIGHT_MED,
prompt_border_active: HIGHLIGHT_HIGH,
accent_model: CYAN,
// Thumb must sit clearly above the track: `ELEVATED` (Σrgb 55)
// was *darker* than the `HIGHLIGHT_LOW` track (Σrgb 62), which
// made the scrollbar invisible — and follow mode blends the
// thumb 40% toward the track, shrinking the delta further.
// `HIGHLIGHT_HIGH` matches the weight of the theme's visible
// chrome (selection border) and Rose Pine's thumb brightness.
scrollbar_bg: HIGHLIGHT_LOW,
scrollbar_fg: HIGHLIGHT_HIGH,
diff_delete_bg: rgb(45, 15, 25),
diff_delete_fg: RED,
diff_insert_bg: rgb(10, 35, 30),
diff_insert_fg: TEAL,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: HIGHLIGHT_MED,
paste_bg: SURFACE,
paste_fg: TEXT_DIM,
paste_dim: MUTED,
md_heading_h1: TEXT,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: PURPLE_BRIGHT,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: TEAL,
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
md_heading_h5: GOLD,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: CYAN,
md_heading_h6_mod: Modifier::BOLD,
md_code: CYAN,
md_task_checked: TEAL,
md_task_unchecked: TEXT_DIM,
md_muted: MUTED,
md_code_bg: SURFACE,
md_text: TEXT,
link_fg: CYAN,
}
}
}
@@ -0,0 +1,117 @@
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
#[allow(dead_code)]
mod palette {
use super::*;
pub const BASE: Color = rgb(35, 33, 54);
pub const SURFACE: Color = rgb(42, 39, 63);
pub const OVERLAY: Color = rgb(57, 53, 82);
pub const MUTED: Color = rgb(110, 106, 134);
pub const SUBTLE: Color = rgb(144, 140, 170);
pub const TEXT: Color = rgb(224, 222, 244);
pub const LOVE: Color = rgb(235, 111, 146);
pub const GOLD: Color = rgb(246, 193, 119);
pub const ROSE: Color = rgb(234, 154, 151);
pub const PINE: Color = rgb(62, 143, 176);
pub const FOAM: Color = rgb(156, 207, 216);
pub const IRIS: Color = rgb(196, 167, 231);
pub const HIGHLIGHT_LOW: Color = rgb(42, 40, 62);
pub const HIGHLIGHT_MED: Color = rgb(68, 65, 90);
pub const HIGHLIGHT_HIGH: Color = rgb(86, 82, 110);
}
use palette::*;
impl Theme {
pub const fn rosepine_moon() -> Self {
Self {
bg_base: BASE,
bg_light: OVERLAY,
bg_dark: SURFACE,
bg_highlight: OVERLAY,
bg_hover: HIGHLIGHT_MED,
bg_terminal: BASE,
accent_user: TEXT,
accent_assistant: IRIS,
accent_thinking: MUTED,
accent_tool: SUBTLE,
accent_system: PINE,
accent_error: LOVE,
accent_success: FOAM,
accent_running: MUTED,
accent_skill: SUBTLE,
text_primary: TEXT,
text_secondary: SUBTLE,
gray_dim: HIGHLIGHT_MED,
gray: MUTED,
gray_bright: SUBTLE,
command: GOLD,
path: ROSE,
running: FOAM,
warning: GOLD,
fuzzy_accent: PINE,
accent_plan: GOLD,
accent_verify: PINE,
accent_feedback: FOAM,
accent_remember: PINE,
selection_border: HIGHLIGHT_HIGH,
hover_border: HIGHLIGHT_MED,
prompt_border: HIGHLIGHT_MED,
prompt_border_active: HIGHLIGHT_HIGH,
accent_model: PINE,
scrollbar_bg: HIGHLIGHT_LOW,
scrollbar_fg: OVERLAY,
diff_delete_bg: rgb(55, 30, 40),
diff_delete_fg: LOVE,
diff_insert_bg: rgb(25, 45, 55),
diff_insert_fg: FOAM,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: HIGHLIGHT_MED,
paste_bg: SURFACE,
paste_fg: SUBTLE,
paste_dim: MUTED,
md_heading_h1: TEXT,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: FOAM,
md_heading_h2_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
md_heading_h3: IRIS,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: ROSE,
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
md_heading_h5: GOLD,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: PINE,
md_heading_h6_mod: Modifier::BOLD,
md_code: FOAM,
md_task_checked: FOAM,
md_task_unchecked: SUBTLE,
md_muted: MUTED,
md_code_bg: SURFACE,
md_text: TEXT,
link_fg: FOAM, // #9ccfd8 -- teal/cyan for dark bg
}
}
}
@@ -0,0 +1,415 @@
//! System appearance detection for automatic day/night theming.
//!
//! Uses the `dark-light` crate for cross-platform detection:
//! - macOS: reads `AppleInterfaceStyle` preference
//! - Linux: queries XDG Desktop Portal (`org.freedesktop.appearance.color-scheme`)
//! - Windows: reads system personalization registry
//!
//! Falls back to OSC 11 terminal background query when `dark-light` returns
//! `Unspecified` (e.g., over SSH where no desktop session is available).
//! The OSC 11 fallback is **startup-only** — see [`detect_with_osc11_fallback`].
//!
//! Falls back to `None` on total detection failure.
use super::ThemeKind;
use std::time::Duration;
use tokio::sync::watch;
/// Detected system appearance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemAppearance {
Light,
Dark,
}
/// Detect the current system appearance (desktop APIs only).
///
/// Detection chain:
/// 1. `dark-light::detect()` — desktop session APIs (macOS/Linux/Windows)
/// 2. `None` — if detection fails
///
/// For the extended chain that includes OSC 11 as a startup-only fallback,
/// see [`detect_with_osc11_fallback`].
///
/// In `#[cfg(test)]` builds, checks the mock override first so that
/// `SystemAppearanceWatcher`'s polling loop (which calls `detect()`
/// directly) is also controllable from tests.
#[must_use]
pub fn detect() -> Option<SystemAppearance> {
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
detect_without_mock()
}
/// Detect system appearance with OSC 11 terminal background fallback.
///
/// Extended detection chain:
/// 1. `dark-light::detect()` — desktop session APIs
/// 2. OSC 11 terminal background query — fallback for SSH/headless
/// 3. `None` — if both fail
///
/// **Startup-only**: the OSC 11 step requires raw-mode stdin access and
/// must NOT be called once crossterm's `EventStream` is active. The
/// live [`SystemAppearanceWatcher`] uses [`detect`] (without OSC 11).
#[must_use]
pub fn detect_with_osc11_fallback() -> Option<SystemAppearance> {
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
detect_without_mock().or_else(super::osc11::detect_via_osc11)
}
/// Inner detection via desktop APIs only (no mock, no OSC 11).
fn detect_without_mock() -> Option<SystemAppearance> {
match dark_light::detect() {
Ok(dark_light::Mode::Dark) => Some(SystemAppearance::Dark),
Ok(dark_light::Mode::Light) => Some(SystemAppearance::Light),
// Mode::Unspecified or Err — no system preference detected
_ => None,
}
}
/// Return the mock value if one has been set (test builds only).
///
/// Returns `Some(value)` when a mock is active, `None` when real
/// detection should proceed.
#[cfg(any(test, feature = "test-support"))]
fn mock_override() -> Option<Option<SystemAppearance>> {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Map system appearance to a theme kind using config-driven overrides.
///
/// `dark_theme` and `light_theme` are the user-configured themes for each
/// appearance mode, read from `[ui].auto_dark_theme` and `[ui].auto_light_theme`
/// in `config.toml`. When `None`, defaults to `GrokNight` / `GrokDay`.
///
/// This function is the single mapping point for appearance -> theme.
/// All callers go through it, making the mapping trivially extensible.
#[must_use]
pub fn to_theme_kind(
appearance: SystemAppearance,
dark_theme: Option<ThemeKind>,
light_theme: Option<ThemeKind>,
) -> ThemeKind {
match appearance {
SystemAppearance::Light => light_theme.unwrap_or(ThemeKind::GrokDay),
SystemAppearance::Dark => dark_theme.unwrap_or(ThemeKind::GrokNight),
}
}
/// Polling interval for system appearance detection.
///
/// In test builds, a shorter interval (50ms) is used so polling tests
/// complete quickly.
#[cfg(not(test))]
const POLL_INTERVAL: Duration = Duration::from_secs(5);
#[cfg(test)]
const POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Watches for system appearance changes via polling.
///
/// The spawned polling task only reads system state and sends via
/// `watch::channel` — it never mutates `theme_cache::CURRENT` or `AUTO_MODE`.
/// The watcher does NOT use OSC 11 for polling — only [`detect()`].
pub struct SystemAppearanceWatcher {
rx: watch::Receiver<Option<SystemAppearance>>,
_handle: tokio::task::JoinHandle<()>,
}
impl SystemAppearanceWatcher {
/// Start the watcher if auto mode is active.
///
/// Returns `None` when `is_auto` is false — the event loop uses
/// `std::future::pending()` in that case so the `select!` branch
/// never fires.
pub fn start_if_auto(is_auto: bool) -> Option<Self> {
if !is_auto {
return None;
}
let initial = detect();
let (tx, rx) = watch::channel(initial);
let interval = POLL_INTERVAL;
let handle = tokio::spawn(async move {
let mut current = initial;
loop {
tokio::time::sleep(interval).await;
let detected = detect();
if detected != current {
current = detected;
let _ = tx.send(current);
}
}
});
Some(Self {
rx,
_handle: handle,
})
}
/// Wait for the next appearance change.
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
self.rx.changed().await
}
/// Return the current detected appearance.
#[must_use]
pub fn current(&self) -> Option<SystemAppearance> {
*self.rx.borrow()
}
}
impl Drop for SystemAppearanceWatcher {
fn drop(&mut self) {
self._handle.abort();
}
}
// -- Test support ----------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
use std::sync::Mutex;
/// Mock override for `detect()`. When set to `Some(value)`, `detect()`
/// returns the mock value instead of calling `dark_light::detect()`.
/// This ensures the `SystemAppearanceWatcher` polling loop (which calls
/// `detect()` directly) is also controllable from tests.
#[cfg(any(test, feature = "test-support"))]
static MOCK_APPEARANCE: Mutex<Option<Option<SystemAppearance>>> = Mutex::new(None);
/// Override `detect()` for tests. Set to `Some(value)` to mock a specific
/// appearance, or `None` to mock detection failure.
#[cfg(any(test, feature = "test-support"))]
pub fn set_mock(value: Option<SystemAppearance>) {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = Some(value);
}
/// Clear the mock override, restoring real detection behavior.
#[cfg(any(test, feature = "test-support"))]
pub fn clear_mock() {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
#[cfg(test)]
mod tests {
use super::super::cache as theme_cache;
use super::*;
/// Helper: set mock, assert `detect()` returns the expected value, clear mock.
/// Caller must hold `theme_cache::test_lock()` to prevent races with parallel
/// tests in `cache::tests` and `slash::commands::theme::tests` that also
/// mutate the shared `MOCK_APPEARANCE` static via `set_mock`/`clear_mock`.
fn assert_mock_roundtrip(value: Option<SystemAppearance>) {
set_mock(value);
assert_eq!(detect(), value);
clear_mock();
}
#[test]
fn to_theme_kind_dark_defaults_to_groknight() {
let result = to_theme_kind(SystemAppearance::Dark, None, None);
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_light_defaults_to_grokday() {
let result = to_theme_kind(SystemAppearance::Light, None, None);
assert_eq!(result, ThemeKind::GrokDay);
}
#[test]
fn to_theme_kind_custom_dark_theme() {
let result = to_theme_kind(SystemAppearance::Dark, Some(ThemeKind::TokyoNight), None);
assert_eq!(result, ThemeKind::TokyoNight);
}
#[test]
fn to_theme_kind_custom_light_theme() {
let result = to_theme_kind(SystemAppearance::Light, None, Some(ThemeKind::RosePineMoon));
assert_eq!(result, ThemeKind::RosePineMoon);
}
#[test]
fn to_theme_kind_custom_both() {
let result = to_theme_kind(
SystemAppearance::Dark,
Some(ThemeKind::RosePineMoon),
Some(ThemeKind::GrokNight),
);
assert_eq!(result, ThemeKind::RosePineMoon);
let result = to_theme_kind(
SystemAppearance::Light,
Some(ThemeKind::RosePineMoon),
Some(ThemeKind::GrokNight),
);
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_dark_ignores_light_override() {
let result = to_theme_kind(SystemAppearance::Dark, None, Some(ThemeKind::TokyoNight));
// Dark appearance should use the dark default, not the light override.
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_light_ignores_dark_override() {
let result = to_theme_kind(SystemAppearance::Light, Some(ThemeKind::TokyoNight), None);
// Light appearance should use the light default, not the dark override.
assert_eq!(result, ThemeKind::GrokDay);
}
#[test]
fn mock_dark_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(Some(SystemAppearance::Dark));
}
#[test]
fn mock_light_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(Some(SystemAppearance::Light));
}
#[test]
fn mock_detection_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(None);
}
#[test]
fn clear_mock_restores_real_detection() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
assert_eq!(detect(), Some(SystemAppearance::Dark));
clear_mock();
// After clearing, detect() calls dark_light::detect() for real.
// We can't assert a specific value since it depends on the system,
// but we can verify it doesn't panic.
let _ = detect();
}
// -- SystemAppearanceWatcher -----------------------------------------
#[tokio::test]
async fn start_if_auto_returns_none_when_not_auto() {
assert!(SystemAppearanceWatcher::start_if_auto(false).is_none());
}
#[tokio::test]
async fn start_if_auto_returns_some_when_auto() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let watcher = SystemAppearanceWatcher::start_if_auto(true);
assert!(watcher.is_some());
clear_mock();
}
#[tokio::test]
async fn watcher_reports_initial_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Light));
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
clear_mock();
}
#[tokio::test]
async fn watcher_reports_none_on_detection_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(None);
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), None);
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
async fn watcher_detects_appearance_change() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
// Change the mock appearance.
set_mock(Some(SystemAppearance::Light));
// Wait for the watcher to detect the change (polls every 50ms in tests).
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
.await
.expect("timed out waiting for change")
.expect("watcher channel closed");
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
async fn watcher_does_not_send_when_unchanged() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
// Wait longer than the poll interval — no change should occur.
let result =
tokio::time::timeout(std::time::Duration::from_millis(200), watcher.changed()).await;
// Should timeout because appearance didn't change.
assert!(
result.is_err(),
"expected timeout — no change should be emitted"
);
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
async fn watcher_detects_recovery_from_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(None); // Initially detection fails
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), None);
// Now detection succeeds.
set_mock(Some(SystemAppearance::Dark));
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
.await
.expect("timed out waiting for recovery")
.expect("watcher channel closed");
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
clear_mock();
}
}
@@ -0,0 +1,298 @@
//! Terminal-native palette for minimal mode.
//!
//! Any RGB theme is designed for one background polarity, so composited on
//! the terminal's own canvas it can land dark-on-dark or light-on-light
//! (e.g. macOS in Light Mode + a dark terminal profile). Polarity detection
//! is not reliable either: OS appearance and OSC 11 both disagree with the
//! actual canvas in edge cases and can change mid-session. Terminal
//! profiles, however, tune their **default** fg/bg to be legible against
//! their own background — this is how `git` and `ls` stay readable on any
//! terminal — so a palette built from `Reset` (body) + sparse named ANSI-16
//! accents is polarity-safe without detection.
//!
//! ## Grays / secondary text
//!
//! Do **not** paint body or status text as `DarkGray` (ANSI bright black).
//! Many dark profiles deliberately set that slot very dark for subtle
//! chrome, which washes out tool stdout and the prompt info bar. Instead:
//!
//! - **Primary content** (`text_primary`, `gray_bright`, …) → `Color::Reset`
//! (terminal default foreground).
//! - **Secondary chrome** (`gray`, `gray_dim`, `text_secondary`) → also
//! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM`
//! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike
//! hard-coding bright black.
use ratatui::style::{Color, Modifier};
use super::Theme;
impl Theme {
/// The fixed terminal-native palette used by minimal mode: every field
/// is `Color::Reset` or a named ANSI-16 color (see the module docs).
pub const fn terminal_default() -> Self {
// Secondary roles store Reset; Theme::muted / Theme::dim apply SGR dim.
const MUTED: Color = Color::Reset;
Self {
bg_base: Color::Reset,
bg_light: Color::Reset,
bg_dark: Color::Reset,
bg_highlight: Color::Reset,
bg_hover: Color::Reset,
bg_terminal: Color::Reset,
accent_user: Color::Reset,
accent_assistant: Color::Magenta,
accent_thinking: MUTED,
accent_tool: MUTED,
accent_system: Color::Blue,
accent_error: Color::Red,
accent_success: Color::Green,
accent_running: Color::Magenta,
accent_skill: Color::Blue,
text_primary: Color::Reset,
text_secondary: MUTED,
gray_dim: MUTED,
gray: MUTED,
gray_bright: Color::Reset,
command: Color::Yellow,
path: Color::Cyan,
running: Color::Cyan,
warning: Color::Yellow,
fuzzy_accent: Color::Cyan,
accent_plan: Color::Yellow,
accent_verify: Color::Magenta,
accent_feedback: Color::Cyan,
accent_remember: Color::Green,
selection_border: MUTED,
hover_border: MUTED,
prompt_border: MUTED,
prompt_border_active: Color::Reset,
accent_model: Color::Cyan,
scrollbar_bg: Color::Reset,
scrollbar_fg: MUTED,
diff_delete_bg: Color::Reset,
diff_delete_fg: Color::Red,
diff_insert_bg: Color::Reset,
diff_insert_fg: Color::Green,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: Color::Reset,
paste_bg: Color::Reset,
paste_fg: MUTED,
paste_dim: MUTED,
md_heading_h1: Color::Reset,
md_heading_h1_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
md_heading_h2: Color::Reset,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: Color::Reset,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: Color::Reset,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: Color::Reset,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: MUTED,
md_heading_h6_mod: Modifier::BOLD,
md_code: Color::Cyan,
md_task_checked: Color::Green,
md_task_unchecked: MUTED,
md_muted: MUTED,
md_code_bg: Color::Reset,
md_text: Color::Reset,
link_fg: Color::Blue,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all_colors(theme: &Theme) -> Vec<(&'static str, Color)> {
vec![
("bg_base", theme.bg_base),
("bg_light", theme.bg_light),
("bg_dark", theme.bg_dark),
("bg_highlight", theme.bg_highlight),
("bg_hover", theme.bg_hover),
("bg_terminal", theme.bg_terminal),
("accent_user", theme.accent_user),
("accent_assistant", theme.accent_assistant),
("accent_thinking", theme.accent_thinking),
("accent_tool", theme.accent_tool),
("accent_system", theme.accent_system),
("accent_error", theme.accent_error),
("accent_success", theme.accent_success),
("accent_running", theme.accent_running),
("accent_skill", theme.accent_skill),
("text_primary", theme.text_primary),
("text_secondary", theme.text_secondary),
("gray_dim", theme.gray_dim),
("gray", theme.gray),
("gray_bright", theme.gray_bright),
("command", theme.command),
("path", theme.path),
("running", theme.running),
("warning", theme.warning),
("fuzzy_accent", theme.fuzzy_accent),
("accent_plan", theme.accent_plan),
("accent_verify", theme.accent_verify),
("accent_feedback", theme.accent_feedback),
("accent_remember", theme.accent_remember),
("selection_border", theme.selection_border),
("hover_border", theme.hover_border),
("prompt_border", theme.prompt_border),
("prompt_border_active", theme.prompt_border_active),
("accent_model", theme.accent_model),
("scrollbar_bg", theme.scrollbar_bg),
("scrollbar_fg", theme.scrollbar_fg),
("diff_delete_bg", theme.diff_delete_bg),
("diff_delete_fg", theme.diff_delete_fg),
("diff_insert_bg", theme.diff_insert_bg),
("diff_insert_fg", theme.diff_insert_fg),
("diff_equal_fg", theme.diff_equal_fg),
("diff_gutter_fg", theme.diff_gutter_fg),
("bg_visual", theme.bg_visual),
("paste_bg", theme.paste_bg),
("paste_fg", theme.paste_fg),
("paste_dim", theme.paste_dim),
("md_heading_h1", theme.md_heading_h1),
("md_heading_h2", theme.md_heading_h2),
("md_heading_h3", theme.md_heading_h3),
("md_heading_h4", theme.md_heading_h4),
("md_heading_h5", theme.md_heading_h5),
("md_heading_h6", theme.md_heading_h6),
("md_code", theme.md_code),
("md_task_checked", theme.md_task_checked),
("md_task_unchecked", theme.md_task_unchecked),
("md_muted", theme.md_muted),
("md_code_bg", theme.md_code_bg),
("md_text", theme.md_text),
("link_fg", theme.link_fg),
]
}
#[test]
fn terminal_default_uses_only_reset_and_named_ansi() {
let theme = Theme::terminal_default();
for (name, color) in all_colors(&theme) {
assert!(
!matches!(color, Color::Rgb(..) | Color::Indexed(_)),
"{name} must be Reset or a named ANSI color, got {color:?}"
);
}
}
#[test]
fn terminal_default_backgrounds_are_transparent() {
let theme = Theme::terminal_default();
for (name, color) in [
("bg_base", theme.bg_base),
("bg_light", theme.bg_light),
("bg_dark", theme.bg_dark),
("bg_terminal", theme.bg_terminal),
("md_code_bg", theme.md_code_bg),
("diff_delete_bg", theme.diff_delete_bg),
("diff_insert_bg", theme.diff_insert_bg),
("paste_bg", theme.paste_bg),
] {
assert_eq!(color, Color::Reset, "{name} must defer to the canvas");
}
}
#[test]
fn terminal_default_leaves_cursor_color_alone() {
let theme = Theme::terminal_default();
assert_eq!(theme.accent_user, Color::Reset);
assert_eq!(
crate::render::color::resolve_to_rgb(theme.accent_user),
None
);
}
#[test]
fn terminal_default_survives_quantization() {
use crate::theme::color_support::ColorLevel;
let theme = Theme::terminal_default();
for level in [
ColorLevel::Basic,
ColorLevel::Ansi256,
ColorLevel::TrueColor,
] {
let quantized = theme.quantized(level);
for ((name, before), (_, after)) in
all_colors(&theme).into_iter().zip(all_colors(&quantized))
{
assert_eq!(before, after, "{name} must survive {level:?}");
}
}
let stripped = theme.quantized(ColorLevel::None);
for (name, color) in all_colors(&stripped) {
assert_eq!(color, Color::Reset, "{name} must strip under NO_COLOR");
}
}
#[test]
fn terminal_default_primary_is_reset_not_dark_gray() {
let theme = Theme::terminal_default();
assert_eq!(theme.text_primary, Color::Reset);
assert_eq!(theme.gray, Color::Reset);
assert_eq!(theme.gray_dim, Color::Reset);
// Must not hard-code bright black for body/secondary roles.
assert_ne!(theme.text_primary, Color::DarkGray);
assert_ne!(theme.gray, Color::DarkGray);
assert_ne!(theme.gray_dim, Color::DarkGray);
}
#[test]
fn terminal_default_muted_and_dim_use_sgr_dim_not_bright_black() {
use ratatui::style::Modifier;
let theme = Theme::terminal_default();
let muted = theme.muted();
let dim = theme.dim();
assert!(
muted.add_modifier.contains(Modifier::DIM),
"muted should DIM the terminal default fg: {muted:?}"
);
assert!(
dim.add_modifier.contains(Modifier::DIM),
"dim should DIM the terminal default fg: {dim:?}"
);
// No explicit DarkGray paint — dim tracks the host palette.
assert!(
muted.fg.is_none() || muted.fg == Some(Color::Reset),
"muted must not set a hard gray: {muted:?}"
);
assert!(
dim.fg.is_none() || dim.fg == Some(Color::Reset),
"dim must not set a hard gray: {dim:?}"
);
}
#[test]
fn rgb_theme_muted_keeps_explicit_gray_without_forced_dim() {
use ratatui::style::Modifier;
// GrokNight paints real RGB grays; muted/dim must not invent DIM.
let theme = Theme::groknight();
assert!(!matches!(theme.gray, Color::Reset));
let muted = theme.muted();
assert_eq!(muted.fg, Some(theme.gray));
assert!(
!muted.add_modifier.contains(Modifier::DIM),
"RGB muted should not force SGR dim: {muted:?}"
);
}
}
@@ -0,0 +1,350 @@
//! TokyoNight theme for the pager.
//!
//! All colors come from the `Theme` struct. NO hardcoded colors elsewhere.
//!
//! The named constants below match the TokyoNight Night/Storm palette from
//! `kigi-tui/src/ui/style.rs` for consistency. The `Theme` struct maps
//! these constants to semantic roles.
use ratatui::style::{Color, Modifier, Style};
/// Helper for concise const Color::Rgb definitions.
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
// TokyoNight palette constants (Night/Storm variant).
// Keep in sync with kigi-tui TokyoNightNight.
#[allow(dead_code)]
pub mod palette {
use super::*;
pub const BG: Color = rgb(26, 27, 38); // #1a1b26 - Night
pub const BG_DARK: Color = rgb(22, 22, 30); // #16161e
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66); // #292e42
pub const BG_STORM: Color = rgb(36, 40, 59); // #24283b - Storm
pub const BG_STORM_DARK: Color = rgb(31, 35, 53); // #1f2335
pub const FG: Color = rgb(192, 202, 245); // #c0caf5
pub const FG_DARK: Color = rgb(169, 177, 214); // #a9b1d6
pub const FG_GUTTER: Color = rgb(59, 66, 97); // #3b4261
pub const COMMENT: Color = rgb(86, 95, 137); // #565f89
pub const DARK3: Color = rgb(84, 92, 126); // #545c7e
pub const DARK5: Color = rgb(115, 122, 162); // #737aa2
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
pub const BLUE1: Color = rgb(42, 195, 222); // #2ac3de
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
pub const RED: Color = rgb(247, 118, 142); // #f7768e
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
}
use palette::*;
/// Theme for v3 pager rendering.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
// Backgrounds
pub bg_base: Color,
pub bg_light: Color,
pub bg_dark: Color,
pub bg_highlight: Color,
pub bg_hover: Color, // Mouse hover row in dropdowns — between bg_highlight and bg_visual
pub bg_terminal: Color, // For terminal output blocks (currently unused, using bg_dark instead)
// Accent colors (for vertical lines)
pub accent_user: Color,
pub accent_assistant: Color,
pub accent_thinking: Color,
pub accent_tool: Color,
pub accent_system: Color,
pub accent_error: Color,
pub accent_success: Color,
pub accent_running: Color, // For tools that are currently running
pub accent_skill: Color, // For skill invocations (slash command skills)
// Text colors
pub text_primary: Color,
pub text_secondary: Color,
// Gray scale (dim → medium → bright)
// Every theme defines these three; they provide a consistent hierarchy
// for secondary/meta text across all themes.
pub gray_dim: Color, // Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
pub gray: Color, // Medium — muted text, comments, collapsed content
pub gray_bright: Color, // Brightest — tool accents, secondary labels
// Semantic colors
pub command: Color, // Yellow for shell commands
pub path: Color, // Orange for file paths
pub running: Color, // Cyan for running indicator
pub warning: Color, // Yellow/amber for warnings
// Search
pub fuzzy_accent: Color, // Highlight color for fuzzy search matches
// Plan mode
pub accent_plan: Color, // Golden accent for plan mode indicator
// Context-window overhead category (context info block)
pub accent_verify: Color, // Violet accent — distinct from plan gold and feedback teal
// Feedback mode
pub accent_feedback: Color, // Teal/green accent for feedback mode
// Remember mode
pub accent_remember: Color, // Green accent for # remember mode
// Selection
pub selection_border: Color,
pub hover_border: Color,
pub prompt_border: Color,
pub prompt_border_active: Color,
// Prompt info
pub accent_model: Color, // Model name in prompt info line
// Scrollbar
pub scrollbar_bg: Color,
pub scrollbar_fg: Color,
// Diff colors
pub diff_delete_bg: Color,
pub diff_delete_fg: Color,
pub diff_insert_bg: Color,
pub diff_insert_fg: Color,
pub diff_equal_fg: Color,
pub diff_gutter_fg: Color,
// Visual selection / dropdown selection background
pub bg_visual: Color,
// Paste elements (chip + preview overlay)
pub paste_bg: Color,
pub paste_fg: Color,
pub paste_dim: Color,
// Markdown rendering colors — used by md_style.rs for headings, code
// blocks, inline code, links, etc. These default to the corresponding
// top-level theme colors but can be overridden per-theme to customise
// markdown appearance independently.
pub md_heading_h1: Color, // H1 headings
pub md_heading_h1_mod: Modifier, // H1 extra effects
pub md_heading_h2: Color, // H2 headings, task unchecked, tables
pub md_heading_h2_mod: Modifier, // H2 extra effects
pub md_heading_h3: Color, // H3 headings, code language tag
pub md_heading_h3_mod: Modifier, // H3 extra effects
pub md_heading_h4: Color, // H4 headings
pub md_heading_h4_mod: Modifier, // H4 extra effects
pub md_heading_h5: Color, // H5 headings, link titles
pub md_heading_h5_mod: Modifier, // H5 extra effects
pub md_heading_h6: Color, // H6 headings
pub md_heading_h6_mod: Modifier, // H6 extra effects
pub md_code: Color, // Inline code, code block delimiters
pub md_task_checked: Color, // Task checked
pub md_task_unchecked: Color, // Task unchecked
pub md_muted: Color, // Blockquotes, list items, rules, links
pub md_code_bg: Color, // Code block background
pub md_text: Color, // Default body text (plain paragraphs, strong, emphasis)
pub link_fg: Color, // Clickable link text color
}
impl Theme {
/// TokyoNight Storm theme.
pub const fn tokyonight() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: BG_HIGHLIGHT,
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(40, 49, 76),
bg_terminal: BG,
accent_user: BLUE,
accent_assistant: MAGENTA,
accent_thinking: FG_GUTTER,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: rgb(100, 180, 170), // Muted teal
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: FG_GUTTER,
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(230, 180, 50), // #E6B432 — golden
accent_verify: MAGENTA, // #bb9af7 — violet (distinct from plan / feedback)
accent_feedback: GREEN1, // #73daca — warm teal/green
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
selection_border: rgb(58, 72, 115), // #3A4873 — muted tokyonight blue
prompt_border: rgb(60, 75, 120), // #323E64 — dimmer prompt chrome
prompt_border_active: rgb(75, 92, 140), // #4B5C8C — brighter when focused
hover_border: rgb(55, 58, 80),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: rgb(85, 15, 20),
diff_delete_fg: RED,
diff_insert_bg: rgb(15, 65, 20),
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(40, 52, 87), // #283457 — blue-tinted selection bg
paste_bg: BG_STORM_DARK,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
// paste_bg: BG_HIGHLIGHT,
// paste_fg: DARK5,
// paste_dim: COMMENT,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: ORANGE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: RED,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: GREEN,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: MAGENTA,
md_heading_h6_mod: Modifier::BOLD,
md_code: GREEN1,
md_task_checked: CYAN,
md_task_unchecked: BLUE,
md_muted: COMMENT,
md_code_bg: BG_HIGHLIGHT,
md_text: FG,
link_fg: BLUE, // #7aa2f7
}
}
/// Get a style with the given foreground color.
pub const fn fg(&self, color: Color) -> Style {
Style::new().fg(color)
}
/// Get a style with muted text (gray — medium).
///
/// When `gray` is [`Color::Reset`] (terminal-native / minimal palette),
/// de-emphasize with [`Modifier::DIM`] instead of painting ANSI bright
/// black — dim scales the terminal's own default fg, so contrast stays
/// polarity-safe. RGB themes keep an explicit gray foreground.
pub const fn muted(&self) -> Style {
match self.gray {
Color::Reset => Style::new().add_modifier(Modifier::DIM),
c => Style::new().fg(c),
}
}
/// Style for OSC 8 hyperlink overlay text.
pub fn link_style(&self) -> Style {
Style::new()
.fg(self.link_fg)
.add_modifier(ratatui::style::Modifier::UNDERLINED)
}
/// Get a style with dim text (gray_dim — dimmest).
///
/// Same Reset→DIM rule as [`Self::muted`] for the terminal-native palette.
pub const fn dim(&self) -> Style {
match self.gray_dim {
Color::Reset => Style::new().add_modifier(Modifier::DIM),
c => Style::new().fg(c),
}
}
/// Get a style for primary text.
pub const fn primary(&self) -> Style {
Style::new().fg(self.text_primary)
}
/// Get a bold style.
pub const fn bold(&self) -> Style {
Style::new().add_modifier(Modifier::BOLD)
}
}
/// Compute animated brightness for a traveling wave effect.
///
/// Creates a wave that travels along the accent line. Each row has a fixed phase
/// offset so the wave appears to move smoothly regardless of block height.
///
/// # Arguments
/// - `tick`: Frame counter (increments each render tick)
/// - `row`: Current row within the block (0 = top)
/// - `wave_rows`: Rows per full wave cycle (e.g., 32)
/// - `speed`: Wave speed (radians per tick, e.g., 0.15)
///
/// # Returns
/// Brightness value in [0.0, 1.0] for this row at this tick.
pub fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f32) -> f32 {
use std::f32::consts::PI;
let rows_per_wave = wave_rows.max(1) as f32;
let phase = (row as f32 / rows_per_wave) * 2.0 * PI;
// Time-based oscillation
let t = tick as f32 * speed;
// sin²(t + phase) gives smooth 0-1 oscillation
let sin_val = (t + phase).sin();
sin_val * sin_val
}
/// Compute a smooth pulsing brightness for a single element (icon, indicator).
///
/// Unlike [`wave_brightness`] which creates a spatial wave across rows,
/// this is a simple temporal pulse: all elements sharing the same tick
/// pulse in unison.
///
/// # Arguments
/// - `tick`: Frame counter (increments each render tick, ~30fps)
/// - `speed`: Pulse speed (radians per tick). The returned value uses
/// `sin²`, which has period π, so the visible bright→dim→bright cycle
/// is `π / (speed * fps)`. At 30fps, `speed = 0.08` ≈ 1.3s per cycle;
/// for a 2.5s cycle pass `speed ≈ 0.042`.
///
/// # Returns
/// Brightness value in [0.0, 1.0].
pub fn pulse_brightness(tick: u64, speed: f32) -> f32 {
let t = tick as f32 * speed;
let sin_val = t.sin();
sin_val * sin_val
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokyonight_theme() {
let theme = Theme::tokyonight();
assert!(matches!(theme.bg_base, Color::Rgb(36, 40, 59)));
assert!(matches!(theme.accent_user, Color::Rgb(122, 162, 247)));
}
}