Skip to content

Commit 23b714e

Browse files
alankyshumclaude
andcommitted
feat: theme-aware markdown preview, runtime settings menu, and --theme CLI flag (v0.8.0)
- Add Settings overlay (`,` key) with runtime dark/light theme toggle (`d` key) - Make markdown preview renderer theme-aware with 13 color fields for light/dark - Add --theme=light|dark|auto CLI flag and SEMANTIC_DIFF_THEME env var - Add COLORFGBG fallback detection for terminals that set it - Auto-size help and settings overlays to fit content width - Fix invisible inline code and H4 headings on light terminal backgrounds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3e2fb6f commit 23b714e

9 files changed

Lines changed: 252 additions & 39 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "semantic-diff"
3-
version = "0.7.3"
3+
version = "0.8.0"
44
edition = "2021"
55
description = "A terminal diff viewer with AI-powered semantic grouping (Claude CLI / Copilot)"
66
license = "MIT"
@@ -16,7 +16,7 @@ tokio = { version = "1", features = ["full"] }
1616
unidiff = "0.4"
1717
syntect = "5.3"
1818
similar = "2"
19-
clap = { version = "4", features = ["derive"] }
19+
clap = { version = "4", features = ["derive", "env"] }
2020
anyhow = "1"
2121
tracing = "0.1"
2222
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

src/app.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub enum InputMode {
2121
Normal,
2222
Search,
2323
Help,
24+
Settings,
2425
}
2526

2627
/// Which panel currently has keyboard focus.
@@ -496,9 +497,36 @@ impl App {
496497
self.input_mode = InputMode::Normal;
497498
None
498499
}
500+
InputMode::Settings => self.handle_key_settings(key),
499501
}
500502
}
501503

504+
/// Handle keys while the Settings overlay is open.
505+
fn handle_key_settings(&mut self, key: KeyEvent) -> Option<Command> {
506+
match key.code {
507+
KeyCode::Char('d') => {
508+
self.toggle_theme();
509+
None
510+
}
511+
KeyCode::Esc => {
512+
self.input_mode = InputMode::Normal;
513+
None
514+
}
515+
_ => None,
516+
}
517+
}
518+
519+
/// Toggle between dark and light theme, rebuilding the highlight cache.
520+
pub fn toggle_theme(&mut self) {
521+
let new_theme = if self.theme.syntect_theme.contains("dark") {
522+
crate::theme::Theme::light()
523+
} else {
524+
crate::theme::Theme::dark()
525+
};
526+
self.theme = new_theme;
527+
self.highlight_cache = HighlightCache::new(&self.diff_data, self.theme.syntect_theme);
528+
}
529+
502530
/// Handle keys in Normal mode.
503531
fn handle_key_normal(&mut self, key: KeyEvent) -> Option<Command> {
504532
// Global keys that work regardless of focused panel
@@ -508,6 +536,10 @@ impl App {
508536
self.input_mode = InputMode::Help;
509537
return None;
510538
}
539+
KeyCode::Char(',') => {
540+
self.input_mode = InputMode::Settings;
541+
return None;
542+
}
511543
KeyCode::Tab => {
512544
self.focused_panel = match self.focused_panel {
513545
FocusedPanel::FileTree => FocusedPanel::DiffView,

src/cli.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use clap::Parser;
2+
use crate::theme::ThemeMode;
23

34
/// A terminal diff viewer with AI-powered semantic grouping.
45
///
@@ -16,11 +17,27 @@ use clap::Parser;
1617
#[derive(Parser, Debug)]
1718
#[command(name = "semantic-diff", version, about)]
1819
pub struct Cli {
20+
/// Color theme: auto, dark, or light. Auto-detects terminal background.
21+
/// Can also be set via SEMANTIC_DIFF_THEME env var.
22+
/// Use --theme=light for SSH/tmux sessions where auto-detection fails.
23+
#[arg(long, value_name = "MODE", env = "SEMANTIC_DIFF_THEME")]
24+
pub theme: Option<String>,
25+
1926
/// Arguments passed through to `git diff` (commits, ranges, --staged, -- paths, etc.)
2027
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
2128
pub git_args: Vec<String>,
2229
}
2330

31+
impl Cli {
32+
pub fn theme_mode(&self) -> ThemeMode {
33+
match self.theme.as_deref() {
34+
Some("dark") => ThemeMode::Dark,
35+
Some("light") => ThemeMode::Light,
36+
_ => ThemeMode::Auto,
37+
}
38+
}
39+
}
40+
2441
impl Cli {
2542
/// Build the full argument list for `git diff`, prepending `-M` for rename detection.
2643
pub fn git_diff_args(&self) -> Vec<String> {
@@ -36,13 +53,14 @@ mod tests {
3653

3754
#[test]
3855
fn test_no_args_produces_bare_diff() {
39-
let cli = Cli { git_args: vec![] };
56+
let cli = Cli { theme: None, git_args: vec![] };
4057
assert_eq!(cli.git_diff_args(), vec!["diff", "-M"]);
4158
}
4259

4360
#[test]
4461
fn test_head_arg() {
4562
let cli = Cli {
63+
theme: None,
4664
git_args: vec!["HEAD".to_string()],
4765
};
4866
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD"]);
@@ -51,6 +69,7 @@ mod tests {
5169
#[test]
5270
fn test_staged_flag() {
5371
let cli = Cli {
72+
theme: None,
5473
git_args: vec!["--staged".to_string()],
5574
};
5675
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--staged"]);
@@ -59,6 +78,7 @@ mod tests {
5978
#[test]
6079
fn test_two_dot_range() {
6180
let cli = Cli {
81+
theme: None,
6282
git_args: vec!["main..feature".to_string()],
6383
};
6484
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main..feature"]);
@@ -67,6 +87,7 @@ mod tests {
6787
#[test]
6888
fn test_three_dot_range() {
6989
let cli = Cli {
90+
theme: None,
7091
git_args: vec!["main...feature".to_string()],
7192
};
7293
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main...feature"]);
@@ -75,6 +96,7 @@ mod tests {
7596
#[test]
7697
fn test_two_refs() {
7798
let cli = Cli {
99+
theme: None,
78100
git_args: vec!["main".to_string(), "feature".to_string()],
79101
};
80102
assert_eq!(
@@ -86,6 +108,7 @@ mod tests {
86108
#[test]
87109
fn test_path_limiter() {
88110
let cli = Cli {
111+
theme: None,
89112
git_args: vec![
90113
"HEAD".to_string(),
91114
"--".to_string(),
@@ -101,6 +124,7 @@ mod tests {
101124
#[test]
102125
fn test_cached_alias() {
103126
let cli = Cli {
127+
theme: None,
104128
git_args: vec!["--cached".to_string()],
105129
};
106130
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--cached"]);
@@ -111,6 +135,7 @@ mod tests {
111135
#[test]
112136
fn test_head_tilde_syntax() {
113137
let cli = Cli {
138+
theme: None,
114139
git_args: vec!["HEAD~3".to_string()],
115140
};
116141
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD~3"]);
@@ -119,6 +144,7 @@ mod tests {
119144
#[test]
120145
fn test_head_caret_syntax() {
121146
let cli = Cli {
147+
theme: None,
122148
git_args: vec!["HEAD^".to_string()],
123149
};
124150
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD^"]);
@@ -127,6 +153,7 @@ mod tests {
127153
#[test]
128154
fn test_sha_refs() {
129155
let cli = Cli {
156+
theme: None,
130157
git_args: vec![
131158
"abc1234".to_string(),
132159
"def5678".to_string(),
@@ -142,6 +169,7 @@ mod tests {
142169
fn test_full_sha() {
143170
let sha = "a".repeat(40);
144171
let cli = Cli {
172+
theme: None,
145173
git_args: vec![sha.clone()],
146174
};
147175
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", &sha]);
@@ -150,6 +178,7 @@ mod tests {
150178
#[test]
151179
fn test_staged_with_ref() {
152180
let cli = Cli {
181+
theme: None,
153182
git_args: vec!["--staged".to_string(), "HEAD~1".to_string()],
154183
};
155184
assert_eq!(
@@ -161,6 +190,7 @@ mod tests {
161190
#[test]
162191
fn test_multiple_path_limiters() {
163192
let cli = Cli {
193+
theme: None,
164194
git_args: vec![
165195
"HEAD".to_string(),
166196
"--".to_string(),
@@ -178,6 +208,7 @@ mod tests {
178208
#[test]
179209
fn test_two_dot_range_with_paths() {
180210
let cli = Cli {
211+
theme: None,
181212
git_args: vec![
182213
"main..feature".to_string(),
183214
"--".to_string(),
@@ -193,6 +224,7 @@ mod tests {
193224
#[test]
194225
fn test_three_dot_range_with_paths() {
195226
let cli = Cli {
227+
theme: None,
196228
git_args: vec![
197229
"origin/main...HEAD".to_string(),
198230
"--".to_string(),
@@ -208,6 +240,7 @@ mod tests {
208240
#[test]
209241
fn test_merge_base_flag() {
210242
let cli = Cli {
243+
theme: None,
211244
git_args: vec!["--merge-base".to_string(), "main".to_string()],
212245
};
213246
assert_eq!(
@@ -219,6 +252,7 @@ mod tests {
219252
#[test]
220253
fn test_no_index_flag() {
221254
let cli = Cli {
255+
theme: None,
222256
git_args: vec![
223257
"--no-index".to_string(),
224258
"file_a.txt".to_string(),
@@ -235,6 +269,7 @@ mod tests {
235269
fn test_many_positional_args_stress() {
236270
let args: Vec<String> = (0..100).map(|i| format!("path_{i}.rs")).collect();
237271
let cli = Cli {
272+
theme: None,
238273
git_args: args.clone(),
239274
};
240275
let result = cli.git_diff_args();
@@ -248,6 +283,7 @@ mod tests {
248283
#[test]
249284
fn test_unicode_path() {
250285
let cli = Cli {
286+
theme: None,
251287
git_args: vec![
252288
"HEAD".to_string(),
253289
"--".to_string(),
@@ -261,6 +297,7 @@ mod tests {
261297
#[test]
262298
fn test_path_with_spaces() {
263299
let cli = Cli {
300+
theme: None,
264301
git_args: vec![
265302
"--".to_string(),
266303
"path with spaces/file.rs".to_string(),
@@ -273,6 +310,7 @@ mod tests {
273310
#[test]
274311
fn test_at_upstream_syntax() {
275312
let cli = Cli {
313+
theme: None,
276314
git_args: vec!["@{upstream}".to_string()],
277315
};
278316
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "@{upstream}"]);
@@ -281,6 +319,7 @@ mod tests {
281319
#[test]
282320
fn test_stash_ref() {
283321
let cli = Cli {
322+
theme: None,
284323
git_args: vec!["stash@{0}".to_string()],
285324
};
286325
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "stash@{0}"]);
@@ -289,6 +328,7 @@ mod tests {
289328
#[test]
290329
fn test_remote_tracking_branch() {
291330
let cli = Cli {
331+
theme: None,
292332
git_args: vec![
293333
"origin/main".to_string(),
294334
"origin/feature/my-branch".to_string(),
@@ -303,6 +343,7 @@ mod tests {
303343
#[test]
304344
fn test_tag_ref() {
305345
let cli = Cli {
346+
theme: None,
306347
git_args: vec!["v1.0.0".to_string(), "v2.0.0".to_string()],
307348
};
308349
assert_eq!(
@@ -314,6 +355,7 @@ mod tests {
314355
#[test]
315356
fn test_diff_filter_flag_passthrough() {
316357
let cli = Cli {
358+
theme: None,
317359
git_args: vec!["--diff-filter=ACMR".to_string(), "HEAD".to_string()],
318360
};
319361
assert_eq!(
@@ -325,6 +367,7 @@ mod tests {
325367
#[test]
326368
fn test_stat_flag_passthrough() {
327369
let cli = Cli {
370+
theme: None,
328371
git_args: vec!["--stat".to_string(), "HEAD".to_string()],
329372
};
330373
assert_eq!(
@@ -336,6 +379,7 @@ mod tests {
336379
#[test]
337380
fn test_name_only_flag_passthrough() {
338381
let cli = Cli {
382+
theme: None,
339383
git_args: vec!["--name-only".to_string()],
340384
};
341385
assert_eq!(
@@ -347,6 +391,7 @@ mod tests {
347391
#[test]
348392
fn test_combined_flags_and_ranges() {
349393
let cli = Cli {
394+
theme: None,
350395
git_args: vec![
351396
"--staged".to_string(),
352397
"--diff-filter=M".to_string(),
@@ -364,6 +409,7 @@ mod tests {
364409
#[test]
365410
fn test_empty_string_arg() {
366411
let cli = Cli {
412+
theme: None,
367413
git_args: vec!["".to_string()],
368414
};
369415
let result = cli.git_diff_args();
@@ -373,6 +419,7 @@ mod tests {
373419
#[test]
374420
fn test_double_dash_only() {
375421
let cli = Cli {
422+
theme: None,
376423
git_args: vec!["--".to_string()],
377424
};
378425
assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--"]);

src/main.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,11 @@ async fn main() -> Result<()> {
6868
let git_diff_args = cli.git_diff_args();
6969
tracing::info!(?git_diff_args, "Git diff args");
7070

71-
// 2c. Load config (creates default if missing)
72-
let config = config::load();
71+
// 2c. Load config (creates default if missing), CLI --theme overrides config
72+
let mut config = config::load();
73+
if cli.theme.is_some() {
74+
config.theme_mode = cli.theme_mode();
75+
}
7376
tracing::info!(?config, "Loaded config");
7477

7578
// 3. Run git diff with user-specified args (or default: unstaged changes)

0 commit comments

Comments
 (0)