kizu 0.7.0

Realtime diff monitor + inline scar review TUI for AI coding agents (Claude Code, etc.)
Documentation
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::Result;
use ratatui::{Terminal, backend::TestBackend};

use crate::app::{
    App, CursorPlacement, DiffSnapshots, ScrollLayout, StreamEvent, ViewMode, WatcherHealth,
};
use crate::git::{DiffContent, DiffLine, FileDiff, FileStatus, Hunk, LineKind};
use crate::hook::{AgentKind, NormalizedHookInput};

pub fn many_hunk_app(hunks: usize, lines_per_hunk: usize, line_width: usize) -> App {
    app_with_files(vec![many_hunk_file(
        "src/generated.rs",
        hunks,
        lines_per_hunk,
        line_width,
        100,
    )])
}

pub fn many_hunk_file(
    name: &str,
    hunks: usize,
    lines_per_hunk: usize,
    line_width: usize,
    secs: u64,
) -> FileDiff {
    let hunks = (0..hunks)
        .map(|idx| {
            let old_start = idx * 10 + 1;
            let lines = (0..lines_per_hunk)
                .map(|line_idx| DiffLine {
                    kind: LineKind::Added,
                    content: bench_line(idx, line_idx, line_width),
                    has_trailing_newline: true,
                })
                .collect::<Vec<_>>();
            Hunk {
                old_start,
                old_count: 0,
                new_start: old_start,
                new_count: lines_per_hunk,
                lines,
                context: Some(format!("fn generated_{idx}()")),
            }
        })
        .collect::<Vec<_>>();
    make_file(name, hunks, secs)
}

pub fn rebuild_layout(app: &mut App) {
    app.build_layout();
}

pub fn large_unified_diff(
    files: usize,
    hunks_per_file: usize,
    lines_per_hunk: usize,
    line_width: usize,
) -> String {
    let mut raw = String::new();
    for file_idx in 0..files {
        let path = format!("src/generated_{file_idx}.rs");
        raw.push_str(&format!(
            "diff --git a/{path} b/{path}\nindex 1111111..2222222 100644\n--- a/{path}\n+++ b/{path}\n"
        ));
        for hunk_idx in 0..hunks_per_file {
            let old_start = hunk_idx * 10 + 1;
            raw.push_str(&format!(
                "@@ -{old_start},0 +{old_start},{lines_per_hunk} @@ fn generated_{hunk_idx}()\n"
            ));
            for line_idx in 0..lines_per_hunk {
                raw.push('+');
                raw.push_str(&bench_line(hunk_idx, line_idx, line_width));
                raw.push('\n');
            }
        }
    }
    raw
}

pub fn parse_unified_diff_for_bench(raw: &str) -> Vec<FileDiff> {
    crate::git::parse_unified_diff(raw).expect("synthetic unified diff must parse")
}

pub fn render_frame_for_bench(app: &App, width: u16, height: u16) {
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).expect("test backend terminal");
    terminal
        .draw(|frame| crate::ui::render(frame, app))
        .expect("render perf frame");
}

pub fn operation_diff_for_bench(previous: &str, current: &str) -> String {
    crate::stream::compute_operation_diff(previous, current)
}

pub fn build_stream_files_for_bench(events: &[StreamEvent]) -> Vec<FileDiff> {
    crate::stream::build_stream_files(events)
}

pub fn hook_payload_json(paths: usize) -> String {
    let edits = (0..paths)
        .map(|idx| format!(r#"{{"file_path":"src/file_{idx}.rs"}}"#))
        .collect::<Vec<_>>()
        .join(",");
    format!(
        r#"{{
  "session_id":"bench-session",
  "hook_event_name":"PostToolUse",
  "tool_name":"MultiEdit",
  "cwd":"/tmp/kizu-perf",
  "tool_input":{{"edits":[{edits}]}}
}}"#
    )
}

pub fn parse_hook_payload_for_bench(payload: &str) -> NormalizedHookInput {
    crate::hook::parse_hook_input(AgentKind::ClaudeCode, payload.as_bytes())
        .expect("synthetic hook payload must parse")
}

pub fn sanitize_hook_event_for_bench(input: &NormalizedHookInput) -> crate::hook::SanitizedEvent {
    crate::hook::sanitize_event(input)
}

pub fn write_scar_target(dir: &Path, lines: usize) -> Result<PathBuf> {
    let path = dir.join("scar_target.rs");
    let content = (0..lines)
        .map(|idx| format!("let value_{idx} = {idx};\n"))
        .collect::<String>();
    std::fs::write(&path, content)?;
    Ok(path)
}

pub fn write_tsx_scar_target(dir: &Path, components: usize) -> Result<(PathBuf, usize)> {
    let path = dir.join("scar_target.tsx");
    let mut content = String::new();
    let target_component = components / 2;
    let mut target_line = 1usize;
    for idx in 0..components {
        if idx == target_component {
            target_line = content.lines().count() + 4;
        }
        content.push_str(&format!(
            "export function Component{idx}({{ count }}: {{ count: number }}) {{\n  return (\n    <section>\n      <p>Count: {{count}}</p>\n    </section>\n  );\n}}\n"
        ));
    }
    std::fs::write(&path, content)?;
    Ok((path, target_line))
}

pub fn scan_scar_files_for_bench(paths: &[PathBuf]) -> Vec<crate::hook::ScarHit> {
    crate::hook::scan_scars(paths)
}

pub fn highlight_lines_for_bench(
    highlighter: &crate::highlight::Highlighter,
    path: &Path,
    lines: &[String],
) -> usize {
    lines
        .iter()
        .map(|line| highlighter.highlight_line(line, path).len())
        .sum()
}

pub fn synthetic_source_lines(lines: usize, line_width: usize) -> Vec<String> {
    (0..lines)
        .map(|idx| bench_line(idx, idx, line_width))
        .collect()
}

pub fn synthetic_tsx_document(components: usize) -> String {
    let mut source = String::new();
    for idx in 0..components {
        source.push_str(&format!(
            "type Props{idx} = {{ count: number; label: string }};\nexport function Component{idx}({{ count, label }}: Props{idx}) {{\n  return (\n    <section className=\"counter\" data-index=\"{idx}\">\n      <h2>{{label}}</h2>\n      <p>Count: {{count}}</p>\n    </section>\n  );\n}}\n"
        ));
    }
    source
}

pub fn stream_events(
    count: usize,
    paths_per_event: usize,
    lines_per_diff: usize,
) -> Vec<StreamEvent> {
    (0..count)
        .map(|event_idx| {
            let mut per_file_diffs = BTreeMap::new();
            let file_paths = (0..paths_per_event)
                .map(|path_idx| {
                    let path = PathBuf::from(format!("src/stream_{event_idx}_{path_idx}.rs"));
                    let mut diff = String::from("@@ -1,0 +1,1 @@\n");
                    for line_idx in 0..lines_per_diff {
                        diff.push('+');
                        diff.push_str(&bench_line(event_idx, line_idx, 48));
                        diff.push('\n');
                    }
                    per_file_diffs.insert(path.clone(), diff);
                    path
                })
                .collect::<Vec<_>>();
            StreamEvent {
                metadata: crate::hook::SanitizedEvent {
                    session_id: Some("bench-session".into()),
                    hook_event_name: "PostToolUse".into(),
                    tool_name: Some("Edit".into()),
                    file_paths,
                    cwd: PathBuf::from("/tmp/kizu-perf"),
                    timestamp_ms: 1_700_000_000_000 + event_idx as u64,
                },
                per_file_diffs,
            }
        })
        .collect()
}

fn bench_line(hunk_idx: usize, line_idx: usize, line_width: usize) -> String {
    let prefix = format!("let generated_{hunk_idx}_{line_idx} = ");
    let fill_len = line_width.saturating_sub(prefix.len());
    format!("{prefix}\"{}\";", "x".repeat(fill_len))
}

fn make_file(name: &str, hunks: Vec<Hunk>, secs: u64) -> FileDiff {
    let added = hunks
        .iter()
        .flat_map(|h| h.lines.iter())
        .filter(|line| line.kind == LineKind::Added)
        .count();
    let deleted = hunks
        .iter()
        .flat_map(|h| h.lines.iter())
        .filter(|line| line.kind == LineKind::Deleted)
        .count();
    FileDiff {
        path: PathBuf::from(name),
        status: FileStatus::Modified,
        added,
        deleted,
        content: DiffContent::Text(hunks),
        mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(secs),
        header_prefix: None,
    }
}

fn app_with_files(mut files: Vec<FileDiff>) -> App {
    files.sort_by_key(|file| file.mtime);
    let mut app = App {
        root: PathBuf::from("/tmp/kizu-perf"),
        git_dir: PathBuf::from("/tmp/kizu-perf/.git"),
        common_git_dir: PathBuf::from("/tmp/kizu-perf/.git"),
        current_branch_ref: Some("refs/heads/main".into()),
        baseline_sha: "abcdef1234567890abcdef1234567890abcdef12".into(),
        files,
        layout: ScrollLayout::default(),
        scroll: 0,
        cursor_sub_row: 0,
        cursor_placement: CursorPlacement::Centered,
        anchor: None,
        help_overlay: false,
        picker: None,
        scar_comment: None,
        revert_confirm: None,
        search_input: None,
        file_view: None,
        search: None,
        seen_hunks: BTreeMap::new(),
        follow_mode: true,
        last_error: None,
        input_health: None,
        head_dirty: false,
        should_quit: false,
        last_body_height: Cell::new(24),
        last_body_width: Cell::new(None),
        visual_top: Cell::new(0.0),
        visual_index_cache: RefCell::new(None),
        anim: None,
        wrap_lines: false,
        show_line_numbers: false,
        watcher_health: WatcherHealth::default(),
        highlighter: std::cell::OnceCell::new(),
        config: crate::config::KizuConfig::default(),
        view_mode: ViewMode::default(),
        saved_diff_scroll: 0,
        saved_stream_scroll: 0,
        stream_events: Vec::new(),
        processed_event_paths: HashSet::new(),
        session_start_ms: 0,
        bound_session_id: None,
        diff_snapshots: DiffSnapshots::default(),
        scar_undo_stack: Vec::new(),
        scar_focus: None,
        pinned_cursor_y: None,
    };
    app.build_layout();
    app.refresh_anchor();
    app
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::DiffContent;

    #[test]
    fn perf_fixture_many_hunk_app_has_requested_shape() {
        let app = many_hunk_app(8, 4, 32);

        assert_eq!(app.files.len(), 1);
        let DiffContent::Text(hunks) = &app.files[0].content else {
            panic!("perf fixture must be text diff");
        };
        assert_eq!(hunks.len(), 8);
        assert_eq!(hunks[0].lines.len(), 4);
        assert!(!app.layout.rows.is_empty());
        assert_eq!(app.layout.hunk_starts.len(), 8);
    }

    #[test]
    fn perf_fixture_large_unified_diff_parses_to_requested_shape() {
        let raw = large_unified_diff(2, 3, 4, 24);
        let files = parse_unified_diff_for_bench(&raw);

        assert_eq!(files.len(), 2);
        for file in files {
            let DiffContent::Text(hunks) = file.content else {
                panic!("perf fixture must parse text diff");
            };
            assert_eq!(hunks.len(), 3);
            assert_eq!(hunks[0].lines.len(), 4);
        }
    }
}