Skip to content

Commit 3f8cf43

Browse files
authored
feat(tui): support light terminal backgrounds with adaptive theme (#265)
* feat(tui): support light terminal backgrounds with adaptive theme Replace the hardcoded dark-only color constants in theme.rs with a runtime Theme struct that has dark() and light() factory constructors. The active theme is stored on App and threaded through all draw functions, enabling the TUI to render correctly on both dark and light terminal backgrounds. - Add Theme struct with 16 semantic style fields and ThemeMode enum - Detect terminal background via COLORFGBG env var at startup - Add --theme dark|light|auto CLI flag and OPENSHELL_THEME env var - Migrate all 499 styles:: references across 11 UI files to app.theme - Clean up 9 inline Style constructions that bypassed the theme system - Add unit tests verifying dark/light palettes and legacy regression Closes #264 * fix(tui): use OSC 11 query for reliable light/dark terminal detection Replace COLORFGBG env var heuristic with terminal-colorsaurus crate, which sends an OSC 11 query to read the actual background color. This fixes auto-detection in iTerm2 and other terminals that don't set COLORFGBG.
1 parent 4604f15 commit 3f8cf43

File tree

19 files changed

+1090
-740
lines changed

19 files changed

+1090
-740
lines changed

.agents/skills/tui-development/SKILL.md

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The OpenShell TUI is a ratatui-based terminal UI for the OpenShell platform. It
2020
- `tokio` — async runtime for event loop, spawned tasks, and mpsc channels
2121
- `navigator-core` — proto-generated types (`NavigatorClient`, request/response structs)
2222
- `navigator-bootstrap` — cluster discovery (`list_clusters()`)
23-
- **Theme:** NVIDIA-branded green on dark terminal background
23+
- **Theme:** Adaptive dark/light via `Theme` struct — NVIDIA-branded green accents. Controlled by `--theme` flag, `OPENSHELL_THEME` env var, or auto-detection.
2424

2525
## 2. Domain Object Hierarchy
2626

@@ -172,44 +172,82 @@ tokio::time::timeout(Duration::from_secs(5), client.health(req)).await
172172

173173
## 5. Style Guide & Colors
174174

175-
### NVIDIA Green Theme (`theme.rs`)
175+
### Theme System (`theme.rs`)
176176

177-
All colors and styles are defined in `crates/navigator-tui/src/theme.rs`.
177+
Colors and styles are defined in `crates/navigator-tui/src/theme.rs` via the `Theme` struct. The TUI supports dark and light terminal backgrounds.
178178

179-
#### Colors (`theme::colors`)
179+
#### Theme selection
180+
181+
Theme mode is controlled by three mechanisms (highest priority first):
182+
183+
1. `--theme dark|light|auto` CLI flag on `openshell term`
184+
2. `OPENSHELL_THEME` environment variable
185+
3. Auto-detection via `COLORFGBG` env var (falls back to dark)
186+
187+
The `ThemeMode` enum (`Auto`, `Dark`, `Light`) is resolved at startup via `theme::detect()` before entering raw mode.
188+
189+
#### Brand colors (`theme::brand`)
180190

181191
| Constant | Value | Usage |
182192
| --- | --- | --- |
183-
| `NVIDIA_GREEN` | `Color::Rgb(118, 185, 0)` | Primary accent — selections, active items, key hints |
184-
| `EVERGLADE` | `Color::Rgb(18, 49, 35)` | Dark green — borders (unfocused), title bar background |
185-
| `BG` | `Color::Black` | Terminal background |
186-
| `FG` | `Color::White` | Default foreground text |
193+
| `NVIDIA_GREEN` | `Color::Rgb(118, 185, 0)` | Primary accent (dark theme) |
194+
| `NVIDIA_GREEN_DARK` | `Color::Rgb(80, 140, 0)` | Primary accent (light theme — darker for contrast) |
195+
| `EVERGLADE` | `Color::Rgb(18, 49, 35)` | Dark green — borders, title bar bg (dark theme) |
196+
| `MAROON` | `Color::Rgb(128, 0, 0)` | Pacman chase animation |
197+
198+
#### Theme struct fields
199+
200+
The `Theme` struct has 16 `Style` fields, accessed at runtime via `app.theme`:
201+
202+
| Field | Dark value | Light value | Usage |
203+
| --- | --- | --- | --- |
204+
| `text` | White fg | Near-black fg | Default body text |
205+
| `muted` | White + DIM | Gray fg | Secondary info, separators |
206+
| `heading` | White + BOLD | Near-black + BOLD | Panel titles, names |
207+
| `accent` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Selected row marker, source labels |
208+
| `accent_bold` | NVIDIA_GREEN + BOLD | NVIDIA_GREEN_DARK + BOLD | Brand text, command prompt |
209+
| `selected` | BOLD only | BOLD only | Selected row emphasis |
210+
| `border` | EVERGLADE fg | Light sage fg | Unfocused panel borders |
211+
| `border_focused` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Focused panel borders |
212+
| `status_ok` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Healthy, INFO, Ready |
213+
| `status_warn` | Yellow fg | Dark yellow fg | Degraded, WARN, Provisioning |
214+
| `status_err` | Red fg | Dark red fg | Unhealthy, ERROR |
215+
| `key_hint` | NVIDIA_GREEN fg | NVIDIA_GREEN_DARK fg | Keyboard shortcut labels |
216+
| `log_cursor` | EVERGLADE bg | Light green bg | Selected log line highlight |
217+
| `claw` | MAROON + BOLD | MAROON + BOLD | Pacman animation |
218+
| `title_bar` | White on EVERGLADE + BOLD | Near-black on light green + BOLD | Title bar strip |
219+
| `badge` | Black on NVIDIA_GREEN + BOLD | White on NVIDIA_GREEN_DARK + BOLD | Notification badges |
220+
221+
#### Accessing the theme in draw functions
222+
223+
The `Theme` is stored on `App` and accessed via a local alias:
224+
225+
```rust
226+
fn draw_my_widget(frame: &mut Frame<'_>, app: &App, area: Rect) {
227+
let t = &app.theme;
228+
frame.render_widget(
229+
Paragraph::new(Span::styled("Hello", t.text)),
230+
area,
231+
);
232+
}
233+
```
187234

188-
#### Styles (`theme::styles`)
235+
For functions that don't take `&App` (e.g., detail popups, helpers), pass `&Theme` as a parameter:
189236

190-
| Constant | Definition | Usage |
191-
| --- | --- | --- |
192-
| `TEXT` | White foreground | Default body text |
193-
| `MUTED` | White + DIM modifier | Secondary info, separators (``), unfocused items |
194-
| `HEADING` | White + BOLD | Panel titles, sandbox/cluster names when active |
195-
| `ACCENT` | NVIDIA_GREEN foreground | Selected row marker (``), sandbox source labels |
196-
| `ACCENT_BOLD` | NVIDIA_GREEN + BOLD | "OpenShell" brand text, command prompt `:` |
197-
| `SELECTED` | BOLD modifier only | Selected row text emphasis |
198-
| `BORDER` | EVERGLADE foreground | Unfocused panel borders |
199-
| `BORDER_FOCUSED` | NVIDIA_GREEN foreground | Focused panel borders |
200-
| `STATUS_OK` | NVIDIA_GREEN foreground | Healthy status, INFO log level, Ready phase |
201-
| `STATUS_WARN` | Yellow foreground | Degraded status, WARN log level, Provisioning phase |
202-
| `STATUS_ERR` | Red foreground | Unhealthy status, ERROR log level, Error phase |
203-
| `KEY_HINT` | NVIDIA_GREEN foreground | Keyboard shortcut labels in nav bar (e.g., `[Tab]`) |
204-
| `TITLE_BAR` | White on EVERGLADE + BOLD | Title bar background strip |
237+
```rust
238+
fn draw_detail_popup(frame: &mut Frame<'_>, data: &MyData, area: Rect, theme: &Theme) {
239+
let t = theme;
240+
// ...
241+
}
242+
```
205243

206244
#### Visual conventions
207245

208246
- **Selected row**: Green `` left-border marker on the selected row. Active gateway also gets a green `` dot.
209-
- **Focused panel**: Border changes from `EVERGLADE` to `NVIDIA_GREEN`.
247+
- **Focused panel**: Border changes from `border` to `border_focused` style.
210248
- **Status indicators**: Green for healthy/ready/info, yellow for degraded/provisioning/warn, red for unhealthy/error.
211249
- **Separators**: Muted `` characters between title bar segments and nav bar sections.
212-
- **Log source labels**: `"sandbox"` source renders in `ACCENT` (green), `"gateway"` in `MUTED`.
250+
- **Log source labels**: `"sandbox"` source renders in `accent` (green), `"gateway"` in `muted`.
213251

214252
## 6. UX Conventions
215253

Cargo.lock

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ indicatif = "0.17"
4646
owo-colors = "4"
4747
ratatui = "0.26"
4848
crossterm = "0.28"
49+
terminal-colorsaurus = "1.0"
4950

5051
# Error handling
5152
miette = { version = "7", features = ["fancy"] }

crates/navigator-cli/src/main.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,11 @@ enum Commands {
450450
// ===================================================================
451451
/// Launch the `OpenShell` interactive TUI.
452452
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
453-
Term,
453+
Term {
454+
/// Color theme for the TUI: auto, dark, or light.
455+
#[arg(long, default_value = "auto", env = "OPENSHELL_THEME")]
456+
theme: navigator_tui::ThemeMode,
457+
},
454458

455459
/// Generate shell completions.
456460
#[command(after_long_help = COMPLETIONS_HELP, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
@@ -2024,12 +2028,12 @@ async fn main() -> Result<()> {
20242028
}
20252029
}
20262030
}
2027-
Some(Commands::Term) => {
2031+
Some(Commands::Term { theme }) => {
20282032
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
20292033
let mut tls = tls.with_gateway_name(&ctx.name);
20302034
apply_edge_auth(&mut tls, &ctx.name);
20312035
let channel = navigator_cli::tls::build_channel(&ctx.endpoint, &tls).await?;
2032-
navigator_tui::run(channel, &ctx.name, &ctx.endpoint).await?;
2036+
navigator_tui::run(channel, &ctx.name, &ctx.endpoint, theme).await?;
20332037
}
20342038
Some(Commands::Completions { shell }) => {
20352039
let exe = std::env::current_exe()

crates/navigator-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ navigator-providers = { path = "../navigator-providers" }
1818

1919
ratatui = { workspace = true }
2020
crossterm = { workspace = true }
21+
terminal-colorsaurus = { workspace = true }
2122
tokio = { workspace = true }
2223
tonic = { workspace = true, features = ["tls"] }
2324
miette = { workspace = true }

crates/navigator-tui/src/app.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ pub struct App {
274274
pub focus: Focus,
275275
pub command_input: String,
276276

277+
/// Active color theme (dark or light).
278+
pub theme: crate::theme::Theme,
279+
277280
/// When the splash screen was shown (for auto-dismiss timing).
278281
pub splash_start: Option<Instant>,
279282

@@ -380,13 +383,19 @@ pub struct App {
380383
}
381384

382385
impl App {
383-
pub fn new(client: NavigatorClient<Channel>, gateway_name: String, endpoint: String) -> Self {
386+
pub fn new(
387+
client: NavigatorClient<Channel>,
388+
gateway_name: String,
389+
endpoint: String,
390+
theme: crate::theme::Theme,
391+
) -> Self {
384392
Self {
385393
running: true,
386394
screen: Screen::Splash,
387395
input_mode: InputMode::Normal,
388396
focus: Focus::Gateways,
389397
command_input: String::new(),
398+
theme,
390399
splash_start: Some(Instant::now()),
391400
gateway_name,
392401
endpoint,

0 commit comments

Comments
 (0)