返回 DeepSeek-TUI-2026
palette.rs
根目录 / crates / tui / src / palette.rs
1 //! DeepSeek color palette and semantic roles.
2
3 use ratatui::style::Color;
4
5 pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5
6 pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);
7 #[allow(dead_code)]
8 pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212);
9 #[allow(dead_code)]
10 pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138);
11 pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
12 pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
13 pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
14
15 // New semantic colors
16 pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
17
18 pub const DEEPSEEK_BLUE: Color = Color::Rgb(
19 DEEPSEEK_BLUE_RGB.0,
20 DEEPSEEK_BLUE_RGB.1,
21 DEEPSEEK_BLUE_RGB.2,
22 );
23 pub const DEEPSEEK_SKY: Color =
24 Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2);
25 #[allow(dead_code)]
26 pub const DEEPSEEK_AQUA: Color = Color::Rgb(
27 DEEPSEEK_AQUA_RGB.0,
28 DEEPSEEK_AQUA_RGB.1,
29 DEEPSEEK_AQUA_RGB.2,
30 );
31 #[allow(dead_code)]
32 pub const DEEPSEEK_NAVY: Color = Color::Rgb(
33 DEEPSEEK_NAVY_RGB.0,
34 DEEPSEEK_NAVY_RGB.1,
35 DEEPSEEK_NAVY_RGB.2,
36 );
37 pub const DEEPSEEK_INK: Color =
38 Color::Rgb(DEEPSEEK_INK_RGB.0, DEEPSEEK_INK_RGB.1, DEEPSEEK_INK_RGB.2);
39 pub const DEEPSEEK_SLATE: Color = Color::Rgb(
40 DEEPSEEK_SLATE_RGB.0,
41 DEEPSEEK_SLATE_RGB.1,
42 DEEPSEEK_SLATE_RGB.2,
43 );
44 pub const DEEPSEEK_RED: Color =
45 Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
46
47 pub const TEXT_BODY: Color = Color::White;
48 pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0
49 pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0
50 pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
51 pub const SELECTION_TEXT: Color = Color::White;
52 pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB
53
54 // Compatibility aliases for existing call sites.
55 pub const TEXT_PRIMARY: Color = TEXT_BODY;
56 pub const TEXT_MUTED: Color = TEXT_SECONDARY;
57 pub const TEXT_DIM: Color = TEXT_HINT;
58
59 // New semantic colors for UI theming
60 pub const BORDER_COLOR: Color =
61 Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
62 #[allow(dead_code)]
63 pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5
64 #[allow(dead_code)]
65 pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2
66 #[allow(dead_code)]
67 pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
68 #[allow(dead_code)]
69 pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
70 #[allow(dead_code)]
71 pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
72 #[allow(dead_code)]
73 pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
74 pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
75 #[allow(dead_code)]
76 pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
77 #[allow(dead_code)]
78 pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C
79 #[allow(dead_code)]
80 pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049
81 #[allow(dead_code)]
82 pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F
83 #[allow(dead_code)]
84 pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24
85 pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); // #123426 dark green tint
86 pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); // #34161C dark red tint
87 pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); // #57C785
88 pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); // #92C6F8
89 pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA
90 pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99
91 pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(205, 216, 228); // #CDD8E4
92
93 // Legacy status colors - keep for backward compatibility
94 pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
95 pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber
96 pub const STATUS_ERROR: Color = DEEPSEEK_RED;
97 #[allow(dead_code)]
98 pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
99
100 // Mode-specific accent colors for mode badges
101 pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
102 pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
103 pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
104
105 pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
106 #[allow(dead_code)]
107 pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
108
109 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
110 pub struct UiTheme {
111 pub name: &'static str,
112 pub composer_bg: Color,
113 pub selection_bg: Color,
114 pub header_bg: Color,
115 pub footer_bg: Color,
116 /// Statusline mode colors (agent/yolo/plan)
117 pub mode_agent: Color,
118 pub mode_yolo: Color,
119 pub mode_plan: Color,
120 /// Statusline status colors
121 pub status_ready: Color,
122 pub status_working: Color,
123 pub status_warning: Color,
124 /// Statusline text colors
125 pub text_dim: Color,
126 pub text_hint: Color,
127 pub text_muted: Color,
128 }
129
130 pub const UI_THEME: UiTheme = UiTheme {
131 name: "whale",
132 composer_bg: DEEPSEEK_SLATE,
133 selection_bg: SELECTION_BG,
134 header_bg: DEEPSEEK_INK,
135 footer_bg: DEEPSEEK_INK,
136 mode_agent: MODE_AGENT,
137 mode_yolo: MODE_YOLO,
138 mode_plan: MODE_PLAN,
139 status_ready: TEXT_MUTED,
140 status_working: DEEPSEEK_SKY,
141 status_warning: STATUS_WARNING,
142 text_dim: TEXT_DIM,
143 text_hint: TEXT_HINT,
144 text_muted: TEXT_MUTED,
145 };
146
147 // === Color depth + brightness helpers (v0.6.6 UI redesign) ===
148
149 /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg
150 /// tints) on terminals that can't render them faithfully.
151 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
152 pub enum ColorDepth {
153 /// 16-color terminals (macOS Terminal.app default, dumb tmux setups).
154 /// Background tints distort the named-palette mapping, so we drop them.
155 Ansi16,
156 /// 256-color terminals — RGB→256 fallback is faithful enough.
157 Ansi256,
158 /// True-color (24-bit) — render the palette verbatim.
159 TrueColor,
160 }
161
162 impl ColorDepth {
163 /// Detect the active terminal's color depth. Honors `COLORTERM`
164 /// (truecolor / 24bit) first, then falls back to `TERM`. Defaults to
165 /// `TrueColor` because most modern terminals support it; the conservative
166 /// fallback is `Ansi16` so background tints disappear safely.
167 #[must_use]
168 pub fn detect() -> Self {
169 if let Ok(ct) = std::env::var("COLORTERM") {
170 let ct = ct.to_ascii_lowercase();
171 if ct.contains("truecolor") || ct.contains("24bit") {
172 return Self::TrueColor;
173 }
174 }
175 if std::env::var_os("WT_SESSION").is_some() {
176 return Self::TrueColor;
177 }
178 if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
179 let term_program = term_program.to_ascii_lowercase();
180 if term_program.contains("iterm")
181 || term_program.contains("wezterm")
182 || term_program.contains("vscode")
183 || term_program.contains("warp")
184 {
185 return Self::TrueColor;
186 }
187 }
188 let term = std::env::var("TERM").unwrap_or_default();
189 let term = term.to_ascii_lowercase();
190 if term.contains("truecolor") || term.contains("24bit") {
191 Self::TrueColor
192 } else if term.contains("256") {
193 Self::Ansi256
194 } else if term.is_empty() || term == "dumb" {
195 Self::Ansi16
196 } else {
197 // Unknown TERM strings should not receive 24-bit SGR by default.
198 // Older macOS/remote terminals can render truecolor backgrounds as
199 // bright cyan blocks; 256-color output is the safer compromise.
200 Self::Ansi256
201 }
202 }
203 }
204
205 /// Adapt a foreground color to the terminal's color depth.
206 ///
207 /// On TrueColor, `color` passes through. On Ansi256 we let ratatui's renderer
208 /// down-convert (it does this already). On Ansi16 we strip RGB to a near
209 /// named color so semantic intent survives even on legacy terminals.
210 #[allow(dead_code)]
211 #[must_use]
212 pub fn adapt_color(color: Color, depth: ColorDepth) -> Color {
213 match (color, depth) {
214 (_, ColorDepth::TrueColor) => color,
215 (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
216 (Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b),
217 _ => color,
218 }
219 }
220
221 /// Adapt a background color. On Ansi16 terminals background tints are noisy,
222 /// so we drop them to `Color::Reset` rather than attempt a coarse named-color
223 /// match — a quiet background reads cleaner than a wrong one.
224 #[allow(dead_code)]
225 #[must_use]
226 pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
227 match (color, depth) {
228 (_, ColorDepth::TrueColor) => color,
229 (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
230 (_, ColorDepth::Ansi256) => color,
231 (_, ColorDepth::Ansi16) => Color::Reset,
232 }
233 }
234
235 /// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not
236 /// RGB falls back to `fg` — there's no meaningful alpha blend on a named
237 /// palette entry.
238 #[must_use]
239 pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
240 let alpha = alpha.clamp(0.0, 1.0);
241 match (fg, bg) {
242 (Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => {
243 let mix = |a: u8, b: u8| -> u8 {
244 let a = f32::from(a);
245 let b = f32::from(b);
246 (b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8
247 };
248 Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb))
249 }
250 _ => fg,
251 }
252 }
253
254 /// Return the reasoning surface color tinted at 12% over the app background.
255 /// This is the headline reasoning treatment in v0.6.6; a 12% blend keeps the
256 /// warm bias subtle without competing with body text. Returns `None` when the
257 /// terminal can't render the bg faithfully.
258 #[must_use]
259 pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
260 match depth {
261 ColorDepth::Ansi16 => None,
262 _ => Some(adapt_bg(
263 blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
264 depth,
265 )),
266 }
267 }
268
269 /// Pulse `color` between 30% and 100% brightness on a 2s cycle keyed off
270 /// `now_ms` (epoch ms). The minimum keeps the glyph readable at trough; the
271 /// maximum is the source color verbatim. Linear interpolation between them
272 /// reads as a slow heartbeat.
273 #[must_use]
274 pub fn pulse_brightness(color: Color, now_ms: u64) -> Color {
275 // 2 s = 2000 ms full cycle; sin gives a smooth 0..1..0 swing.
276 let phase = (now_ms % 2000) as f32 / 2000.0;
277 let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; // 0..1
278 let alpha = 0.30 + t * 0.70; // 30%..100%
279 match color {
280 Color::Rgb(r, g, b) => {
281 let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 };
282 Color::Rgb(s(r), s(g), s(b))
283 }
284 other => other,
285 }
286 }
287
288 /// Map an RGB triple to its closest ANSI-16 named color. Only used by
289 /// `adapt_color` on Ansi16 terminals; we lean on hue dominance + lightness so
290 /// brand colors land on the obviously-related named entry (sky → cyan, blue →
291 /// blue, red → red, etc.) rather than dithering around grey.
292 #[allow(dead_code)]
293 fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color {
294 let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
295 if lum < 24 {
296 return Color::Black;
297 }
298 if r > 220 && g > 220 && b > 220 {
299 return Color::White;
300 }
301 let bright = lum > 144;
302 let max = r.max(g).max(b);
303 let min = r.min(g).min(b);
304 if max.saturating_sub(min) < 16 {
305 return if bright { Color::Gray } else { Color::DarkGray };
306 }
307 if r >= g && r >= b {
308 if g > b + 24 {
309 if bright {
310 Color::LightYellow
311 } else {
312 Color::Yellow
313 }
314 } else if b > r.saturating_sub(24) {
315 if bright {
316 Color::LightMagenta
317 } else {
318 Color::Magenta
319 }
320 } else if bright {
321 Color::LightRed
322 } else {
323 Color::Red
324 }
325 } else if g >= r && g >= b {
326 if b > r + 24 {
327 if bright {
328 Color::LightCyan
329 } else {
330 Color::Cyan
331 }
332 } else if bright {
333 Color::LightGreen
334 } else {
335 Color::Green
336 }
337 } else if r.saturating_add(48) >= b && r > g + 24 {
338 if bright {
339 Color::LightMagenta
340 } else {
341 Color::Magenta
342 }
343 } else if g.saturating_add(48) >= b && g > r + 24 {
344 if bright {
345 Color::LightCyan
346 } else {
347 Color::Cyan
348 }
349 } else if bright {
350 Color::LightBlue
351 } else {
352 Color::Blue
353 }
354 }
355
356 /// Map an RGB color to the nearest xterm 256-color palette index. We use only
357 /// the stable 6x6x6 cube and grayscale ramp (16..255), not the terminal's
358 /// user-configurable 0..15 colors.
359 #[allow(dead_code)]
360 fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
361 const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
362
363 fn nearest_cube_level(channel: u8) -> usize {
364 CUBE_LEVELS
365 .iter()
366 .enumerate()
367 .min_by_key(|(_, level)| channel.abs_diff(**level))
368 .map(|(idx, _)| idx)
369 .unwrap_or(0)
370 }
371
372 fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
373 let dr = i32::from(a.0) - i32::from(b.0);
374 let dg = i32::from(a.1) - i32::from(b.1);
375 let db = i32::from(a.2) - i32::from(b.2);
376 (dr * dr + dg * dg + db * db) as u32
377 }
378
379 let ri = nearest_cube_level(r);
380 let gi = nearest_cube_level(g);
381 let bi = nearest_cube_level(b);
382 let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]);
383 let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8;
384
385 let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8;
386 let gray_i = if avg <= 8 {
387 0
388 } else if avg >= 238 {
389 23
390 } else {
391 ((u16::from(avg) - 8 + 5) / 10).min(23) as u8
392 };
393 let gray = 8 + 10 * gray_i;
394 let gray_index = 232 + gray_i;
395
396 if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) {
397 gray_index
398 } else {
399 cube_index
400 }
401 }
402
403 #[cfg(test)]
404 mod tests {
405 use super::{
406 ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY,
407 SURFACE_REASONING, adapt_bg, adapt_color, blend, nearest_ansi16, pulse_brightness,
408 reasoning_surface_tint, rgb_to_ansi256,
409 };
410 use ratatui::style::Color;
411
412 #[test]
413 fn adapt_color_passes_through_truecolor() {
414 let c = Color::Rgb(53, 120, 229);
415 assert_eq!(adapt_color(c, ColorDepth::TrueColor), c);
416 }
417
418 #[test]
419 fn adapt_color_maps_rgb_to_indexed_on_ansi256() {
420 let c = Color::Rgb(53, 120, 229);
421 assert!(matches!(
422 adapt_color(c, ColorDepth::Ansi256),
423 Color::Indexed(_)
424 ));
425 }
426
427 #[test]
428 fn adapt_bg_maps_rgb_to_indexed_on_ansi256() {
429 assert!(matches!(
430 adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256),
431 Color::Indexed(_)
432 ));
433 }
434
435 #[test]
436 fn adapt_color_drops_to_named_on_ansi16() {
437 // Sky: blue-dominant and bright → LightBlue, not terminal cyan.
438 assert_eq!(
439 adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16),
440 Color::LightBlue
441 );
442 // Red: red-dominant, mid lum → Red (not the bright variant).
443 assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red);
444 }
445
446 #[test]
447 fn adapt_bg_disables_tints_on_ansi16() {
448 assert_eq!(
449 adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16),
450 Color::Reset
451 );
452 assert_eq!(
453 adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor),
454 SURFACE_REASONING
455 );
456 }
457
458 #[test]
459 fn reasoning_tint_is_none_on_ansi16() {
460 assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none());
461 assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some());
462 assert!(matches!(
463 reasoning_surface_tint(ColorDepth::Ansi256),
464 Some(Color::Indexed(_))
465 ));
466 }
467
468 #[test]
469 fn blend_at_zero_returns_bg_at_one_returns_fg() {
470 let fg = Color::Rgb(200, 100, 50);
471 let bg = Color::Rgb(0, 0, 0);
472 assert_eq!(blend(fg, bg, 0.0), bg);
473 assert_eq!(blend(fg, bg, 1.0), fg);
474 }
475
476 #[test]
477 fn blend_at_half_is_midpoint() {
478 let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5);
479 assert_eq!(mid, Color::Rgb(100, 50, 0));
480 }
481
482 #[test]
483 fn pulse_brightness_swings_within_envelope() {
484 // The pulse rides between 30%..100% — never below 30% of the source.
485 let src = ACCENT_REASONING_LIVE;
486 let mut min_r = u8::MAX;
487 let mut max_r = 0u8;
488 for ms in (0u64..2000).step_by(50) {
489 if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) {
490 min_r = min_r.min(r);
491 max_r = max_r.max(r);
492 }
493 }
494 let Color::Rgb(src_r, _, _) = src else {
495 panic!("expected RGB");
496 };
497 // Trough should land near 30% of source; crest near source itself.
498 let lower = (f32::from(src_r) * 0.30).round() as u8;
499 assert!(min_r <= lower + 2, "trough too high: {min_r}");
500 assert!(max_r + 2 >= src_r, "crest too low: {max_r}");
501 }
502
503 #[test]
504 fn pulse_passes_named_colors_unchanged() {
505 // Named palette entries don't blend meaningfully — leave them alone.
506 assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset);
507 assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan);
508 }
509
510 #[test]
511 fn nearest_ansi16_routes_known_brand_colors() {
512 // Blue-dominant brand colors should stay blue rather than collapsing
513 // to the user's terminal cyan, which is often much louder.
514 assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue);
515 assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue);
516 assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue);
517 assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan);
518 assert_eq!(nearest_ansi16(226, 80, 96), Color::Red);
519 assert_eq!(nearest_ansi16(11, 21, 38), Color::Black);
520 }
521
522 #[test]
523 fn rgb_to_ansi256_uses_stable_extended_palette() {
524 assert!(rgb_to_ansi256(53, 120, 229) >= 16);
525 assert!(rgb_to_ansi256(11, 21, 38) >= 16);
526 }
527
528 #[test]
529 fn color_depth_detect_is_safe_without_env() {
530 // Don't try to pin the result — env may be anything in CI. Just
531 // exercise the path so a panic would surface.
532 let _ = ColorDepth::detect();
533 let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect());
534 }
535 }
536
536 lines RUST