Skip to content

Commit eeba538

Browse files
authored
Merge pull request #19 from thinkgrid-labs/dev
Dev
2 parents d3b7beb + 9e4e74b commit eeba538

9 files changed

Lines changed: 317 additions & 151 deletions

File tree

Cargo.toml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ resolver = "2"
99
[workspace.dependencies]
1010
serde = { version = "1", features = ["derive"] }
1111
serde_json = "1"
12-
candle-core = { version = "0.8" }
13-
candle-nn = { version = "0.8" }
14-
candle-transformers = { version = "0.8" }
12+
candle-core = { version = "0.10.2" }
13+
candle-nn = { version = "0.10.2" }
14+
candle-transformers = { version = "0.10.2" }
1515
tokenizers = { version = "0.21.0", default-features = false, features = ["fancy-regex"] }
16-
rand = "0.9"
17-
ratatui = "0.29"
18-
crossterm = "0.28"
16+
rand = "0.10.1"
17+
ratatui = "0.30"
18+
crossterm = "0.29"
1919
tokio = { version = "1", features = ["full"] }
2020
anyhow = "1"
2121
clap = { version = "4", features = ["derive"] }
2222
regex = "1"
2323
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls-native-roots"] }
24-
indicatif = "0.17"
24+
indicatif = "0.18.4"
2525
walkdir = "2"
2626
chrono = { version = "0.4", features = ["serde"] }
2727
lazy_static = "1"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Auto-detects your OS and CPU architecture (Intel or Apple Silicon), downloads th
5454
Pin a specific version:
5555

5656
```bash
57-
VERSION=v0.6.0 curl -fsSL https://github.com/thinkgrid-labs/diffmind/releases/latest/download/install.sh | bash
57+
VERSION=v.x.x curl -fsSL https://github.com/thinkgrid-labs/diffmind/releases/latest/download/install.sh | bash
5858
```
5959

6060
### Windows

apps/tui-cli/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
name = "diffmind"
3-
version = "0.6.2"
4-
edition = "2021"
3+
version = "0.6.3"
4+
edition = "2024"
55
description = "Local-first AI code review agent — powered by on-device inference"
66

77
[[bin]]

apps/tui-cli/src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ pub struct Cli {
6464
#[arg(long)]
6565
pub debug: bool,
6666

67+
/// Inference device: auto (default), cpu, metal.
68+
/// `auto` tries Metal on Apple Silicon and falls back to CPU.
69+
/// `metal` forces GPU inference (macOS only).
70+
#[arg(long, default_value = "auto")]
71+
pub device: String,
72+
6773
/// Specific files or directories to review (optional)
6874
pub files: Vec<String>,
6975
}

apps/tui-cli/src/git.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,7 @@ pub fn current_branch() -> Option<String> {
1111
if output.status.success() {
1212
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
1313
// "HEAD" means detached state — not useful to show
14-
if branch == "HEAD" {
15-
None
16-
} else {
17-
Some(branch)
18-
}
14+
if branch == "HEAD" { None } else { Some(branch) }
1915
} else {
2016
None
2117
}

apps/tui-cli/src/main.rs

Lines changed: 128 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::Result;
2-
use core_engine::{ReviewAnalyzer, ReviewFinding, Severity};
2+
use core_engine::{DevicePreference, ReviewAnalyzer, ReviewFinding, Severity};
33
use indicatif::{ProgressBar, ProgressStyle};
44
use std::{
55
collections::HashSet,
@@ -83,6 +83,16 @@ async fn main() -> Result<()> {
8383
Ok(())
8484
}
8585

86+
// ─── Device helpers ──────────────────────────────────────────────────────────
87+
88+
fn parse_device(s: &str) -> DevicePreference {
89+
match s.to_lowercase().as_str() {
90+
"metal" => DevicePreference::Metal,
91+
"cpu" => DevicePreference::Cpu,
92+
_ => DevicePreference::Auto,
93+
}
94+
}
95+
8696
// ─── Severity helpers ────────────────────────────────────────────────────────
8797

8898
fn parse_severity(s: &str) -> Severity {
@@ -262,14 +272,15 @@ async fn run_static(
262272
// ── RAG context ───────────────────────────────────────────────────────────
263273
let index = Indexer::load(project_root);
264274
let mut context = String::new();
265-
if let Some(idx) = index {
266-
if let Some(rag_text) = rag::get_rag_context(diff, &idx) {
267-
context = rag_text;
268-
}
275+
if let Some(idx) = index
276+
&& let Some(rag_text) = rag::get_rag_context(diff, &idx)
277+
{
278+
context = rag_text;
269279
}
270280

271281
// ── Build analyzer ────────────────────────────────────────────────────────
272-
let mut analyzer = ReviewAnalyzer::new(&model_bytes, &tokenizer_bytes)
282+
let device_pref = parse_device(&args.device);
283+
let mut analyzer = ReviewAnalyzer::new_with_device(&model_bytes, &tokenizer_bytes, device_pref)
273284
.map_err(|e| anyhow::anyhow!(e.to_string()))?
274285
.with_languages(langs)
275286
.with_debug(args.debug);
@@ -304,7 +315,7 @@ async fn run_static(
304315
});
305316

306317
let pb = spinner.clone();
307-
let (all_findings, skipped) = analyzer
318+
let (summary, skipped) = analyzer
308319
.analyze_diff_chunked_with_progress(diff, &context, args.max_tokens, move |done, total| {
309320
*chunk_label.lock().unwrap() = format!("chunk {}/{}", done, total);
310321
pb.set_message(format!("Analyzing chunk {}/{}...", done, total));
@@ -323,39 +334,47 @@ async fn run_static(
323334
);
324335
}
325336

326-
// ── Filter to threshold ───────────────────────────────────────────────────
327-
let findings: Vec<&ReviewFinding> = all_findings
337+
// ── Filter findings to threshold ──────────────────────────────────────────
338+
let findings: Vec<&ReviewFinding> = summary
339+
.findings
328340
.iter()
329341
.filter(|f| meets_threshold(&f.severity, &min_severity))
330342
.collect();
331343

332-
if findings.is_empty() {
333-
if skipped > 0 {
334-
eprintln!(" ? No parseable findings — try `--model 3b` for better output quality.");
335-
} else {
336-
eprintln!(" ✓ No issues found.");
337-
}
338-
eprintln!();
339-
return Ok(false);
340-
}
341-
342344
match args.format {
343345
cli::OutputFormat::Json => {
344-
// Emit a clean JSON array — pipe-friendly for CI dashboards
345-
let json = serde_json::to_string_pretty(&findings)
346-
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
346+
// Emit the full summary as JSON — pipe-friendly for CI dashboards
347+
let out = serde_json::json!({
348+
"findings": findings,
349+
"positives": summary.positives,
350+
"suggestions": summary.suggestions,
351+
});
352+
let json =
353+
serde_json::to_string_pretty(&out).map_err(|e| anyhow::anyhow!(e.to_string()))?;
347354
println!("{}", json);
348355
}
349356
cli::OutputFormat::Text => {
350357
println!();
351-
for (i, f) in findings.iter().enumerate() {
352-
print_finding(f, i + 1, findings.len());
358+
if findings.is_empty() {
359+
if skipped > 0 {
360+
eprintln!(
361+
" ? No parseable findings — try `--model 3b` for better output quality."
362+
);
363+
} else {
364+
use crossterm::style::Stylize;
365+
eprintln!(" {} No issues found.", "✓".green().bold());
366+
}
367+
} else {
368+
for (i, f) in findings.iter().enumerate() {
369+
print_finding(f, i + 1, findings.len());
370+
}
371+
print_summary(findings.len(), skipped);
353372
}
354-
print_summary(findings.len(), skipped);
373+
print_positives_and_suggestions(&summary.positives, &summary.suggestions);
355374
}
356375
}
357376

358-
Ok(true)
377+
Ok(!findings.is_empty())
359378
}
360379

361380
// ─── Coloured finding renderer ────────────────────────────────────────────────
@@ -478,19 +497,45 @@ fn print_summary(count: usize, skipped: usize) {
478497
eprintln!();
479498
}
480499

500+
fn print_positives_and_suggestions(positives: &[String], suggestions: &[String]) {
501+
if positives.is_empty() && suggestions.is_empty() {
502+
return;
503+
}
504+
505+
if !positives.is_empty() {
506+
eprintln!(" {}", "─".repeat(62).dark_grey());
507+
eprintln!(" {} What looks good", "✓".green().bold());
508+
for p in positives {
509+
eprintln!(" {} {}", "·".green(), p);
510+
}
511+
eprintln!();
512+
}
513+
514+
if !suggestions.is_empty() {
515+
if positives.is_empty() {
516+
eprintln!(" {}", "─".repeat(62).dark_grey());
517+
}
518+
eprintln!(" 💡 Suggestions");
519+
for s in suggestions {
520+
eprintln!(" {} {}", "·".dark_yellow(), s);
521+
}
522+
eprintln!();
523+
}
524+
}
525+
481526
// ─── TUI runner ───────────────────────────────────────────────────────────────
482527

483528
use crossterm::{
484529
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
485530
execute,
486-
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
531+
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
487532
};
488533
use ratatui::{
534+
Frame, Terminal,
489535
backend::{Backend, CrosstermBackend},
490536
layout::{Constraint, Direction, Layout},
491537
style::{Color, Modifier, Style},
492538
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
493-
Frame, Terminal,
494539
};
495540

496541
struct App {
@@ -543,57 +588,60 @@ async fn run_tui(
543588
res
544589
}
545590

546-
async fn tui_loop<B: Backend>(terminal: &mut Terminal<B>, app: Arc<Mutex<App>>) -> Result<()> {
591+
async fn tui_loop<B: Backend>(terminal: &mut Terminal<B>, app: Arc<Mutex<App>>) -> Result<()>
592+
where
593+
B::Error: Send + Sync + 'static,
594+
{
547595
loop {
548596
{
549597
let mut app_lock = app.lock().await;
550598
terminal.draw(|f| ui(f, &mut app_lock))?;
551599
}
552600

553-
if event::poll(Duration::from_millis(100))? {
554-
if let Event::Key(key) = event::read()? {
555-
let mut app_lock = app.lock().await;
556-
match key.code {
557-
KeyCode::Char('q') => return Ok(()),
558-
KeyCode::Down | KeyCode::Char('j') => {
559-
let i = match app_lock.state.selected() {
560-
Some(i) if !app_lock.findings.is_empty() => {
561-
(i + 1) % app_lock.findings.len()
562-
}
563-
_ => 0,
564-
};
565-
app_lock.state.select(Some(i));
566-
}
567-
KeyCode::Up | KeyCode::Char('k') => {
568-
let i = match app_lock.state.selected() {
569-
Some(i) if !app_lock.findings.is_empty() => {
570-
if i == 0 {
571-
app_lock.findings.len() - 1
572-
} else {
573-
i - 1
574-
}
601+
if event::poll(Duration::from_millis(100))?
602+
&& let Event::Key(key) = event::read()?
603+
{
604+
let mut app_lock = app.lock().await;
605+
match key.code {
606+
KeyCode::Char('q') => return Ok(()),
607+
KeyCode::Down | KeyCode::Char('j') => {
608+
let i = match app_lock.state.selected() {
609+
Some(i) if !app_lock.findings.is_empty() => {
610+
(i + 1) % app_lock.findings.len()
611+
}
612+
_ => 0,
613+
};
614+
app_lock.state.select(Some(i));
615+
}
616+
KeyCode::Up | KeyCode::Char('k') => {
617+
let i = match app_lock.state.selected() {
618+
Some(i) if !app_lock.findings.is_empty() => {
619+
if i == 0 {
620+
app_lock.findings.len() - 1
621+
} else {
622+
i - 1
575623
}
576-
_ => 0,
577-
};
578-
app_lock.state.select(Some(i));
579-
}
580-
KeyCode::Char('a') => {
581-
if !app_lock.analyzing {
582-
app_lock.analyzing = true;
583-
app_lock.status = "Analyzing...".to_string();
584-
let app_clone = Arc::clone(&app);
585-
tokio::spawn(async move {
586-
let app_err = Arc::clone(&app_clone);
587-
if let Err(e) = background_analysis(app_clone).await {
588-
let mut app = app_err.lock().await;
589-
app.status = format!("Error: {}", e);
590-
app.analyzing = false;
591-
}
592-
});
593624
}
625+
_ => 0,
626+
};
627+
app_lock.state.select(Some(i));
628+
}
629+
KeyCode::Char('a') => {
630+
if !app_lock.analyzing {
631+
app_lock.analyzing = true;
632+
app_lock.status = "Analyzing...".to_string();
633+
let app_clone = Arc::clone(&app);
634+
tokio::spawn(async move {
635+
let app_err = Arc::clone(&app_clone);
636+
if let Err(e) = background_analysis(app_clone).await {
637+
let mut app = app_err.lock().await;
638+
app.status = format!("Error: {}", e);
639+
app.analyzing = false;
640+
}
641+
});
594642
}
595-
_ => {}
596643
}
644+
_ => {}
597645
}
598646
}
599647
}
@@ -619,28 +667,29 @@ async fn background_analysis(app: Arc<Mutex<App>>) -> Result<()> {
619667

620668
let index = Indexer::load(&project_root);
621669
let mut context = String::new();
622-
if let Some(idx) = index {
623-
if let Some(rag_text) = rag::get_rag_context(&diff, &idx) {
624-
context = rag_text;
625-
}
670+
if let Some(idx) = index
671+
&& let Some(rag_text) = rag::get_rag_context(&diff, &idx)
672+
{
673+
context = rag_text;
626674
}
627675

628676
let langs = detect_languages(&diff);
629-
let mut analyzer = ReviewAnalyzer::new(&model_bytes, &tokenizer_bytes)
630-
.map_err(|e| anyhow::anyhow!(e.to_string()))?
631-
.with_languages(langs);
677+
let mut analyzer =
678+
ReviewAnalyzer::new_with_device(&model_bytes, &tokenizer_bytes, DevicePreference::Auto)
679+
.map_err(|e| anyhow::anyhow!(e.to_string()))?
680+
.with_languages(langs);
632681

633682
if let Some(req) = ticket {
634683
analyzer = analyzer.with_requirements(req);
635684
}
636685

637-
let findings = analyzer
686+
let summary = analyzer
638687
.analyze_diff_chunked(&diff, &context, 1024)
639688
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
640689

641690
let mut app_lock = app.lock().await;
642-
let count = findings.len();
643-
app_lock.findings = findings;
691+
let count = summary.findings.len();
692+
app_lock.findings = summary.findings;
644693
app_lock.status = format!(
645694
"Done — {} finding{}",
646695
count,

apps/tui-cli/src/rag.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::indexer::{SymbolIndex, COMMON_KEYWORDS};
1+
use crate::indexer::{COMMON_KEYWORDS, SymbolIndex};
22
use regex::Regex;
33

44
const MAX_CONTEXT_BYTES: usize = 3000;

0 commit comments

Comments
 (0)