dirge-agent 0.7.4

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
use std::path::{Path, PathBuf};

use crate::agent::tools::background::BackgroundStore;
use crate::agent::tools::bg_shell::BackgroundShellStore;
use crate::session::Session;

pub struct StatusLine;

fn fmt_tokens(n: u64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 1_000 {
        format!("{}k", n / 1000)
    } else {
        n.to_string()
    }
}

/// Find the current git branch for `start`, walking up parent
/// directories until we hit a `.git` entry (file for worktrees,
/// directory for the main checkout) or the filesystem root. Returns
/// `None` when the directory isn't inside a git working tree, or
/// when `.git/HEAD` is unreadable / detached / malformed (the status
/// line is informational, not a git porcelain — we just omit the
/// segment in those cases).
fn git_branch(start: &Path) -> Option<String> {
    let head_path = find_git_head(start)?;
    let head = std::fs::read_to_string(head_path).ok()?;
    let head = head.trim();
    head.strip_prefix("ref: refs/heads/").map(|b| b.to_string())
}

fn find_git_head(start: &Path) -> Option<PathBuf> {
    let mut cur: PathBuf = start.to_path_buf();
    loop {
        let dot_git = cur.join(".git");
        if dot_git.is_dir() {
            return Some(dot_git.join("HEAD"));
        }
        if dot_git.is_file() {
            // Worktree pointer: `gitdir: <path>` → HEAD lives there.
            let txt = std::fs::read_to_string(&dot_git).ok()?;
            let gitdir = txt.trim().strip_prefix("gitdir: ")?;
            return Some(PathBuf::from(gitdir).join("HEAD"));
        }
        if !cur.pop() {
            return None;
        }
    }
}

/// Cached wrapper around [`git_branch`]. `StatusLine::render` runs on every
/// keystroke, and the raw `.git/HEAD` directory walk it did there is
/// synchronous filesystem I/O — repeated per painted frame, it froze the UI on
/// slow storage / large repos (dirge-vuzz). The branch only changes on
/// checkout, so cache it per working-dir for a few seconds: the FS walk now
/// runs at most once every `TTL`, not once per frame. (The background
/// `gitstatus` poller already refreshes on its own cadence; this just keeps the
/// status line's own lookup off the hot path.)
fn cached_git_branch(start: &Path) -> Option<String> {
    use std::sync::Mutex;
    use std::time::{Duration, Instant};
    const TTL: Duration = Duration::from_secs(3);
    static CACHE: Mutex<Option<(Instant, PathBuf, Option<String>)>> = Mutex::new(None);

    let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
    if let Some((at, dir, branch)) = guard.as_ref()
        && dir.as_path() == start
        && at.elapsed() < TTL
    {
        return branch.clone();
    }
    let fresh = git_branch(start);
    *guard = Some((Instant::now(), start.to_path_buf(), fresh.clone()));
    fresh
}

impl StatusLine {
    #[allow(clippy::too_many_arguments)]
    pub fn render(
        session: &Session,
        is_running: bool,
        _spinner_tick: u64,
        loop_label: Option<&str>,
        prompt_name: Option<&str>,
        perm_mode: Option<&str>,
        bg_store: Option<&BackgroundStore>,
        shell_store: Option<&BackgroundShellStore>,
        sandbox_badge: Option<&'static str>,
    ) -> String {
        let state = if is_running { "running" } else { "ready" };
        let wd_path = Path::new(&session.working_dir);
        let dir = wd_path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(&session.working_dir);
        // Append `:branch` when the working dir is inside a git
        // working tree. Detached HEAD / non-git dirs show just the
        // project name.
        let project_label = match cached_git_branch(wd_path) {
            Some(b) => format!("{}:{}", dir, b),
            None => dir.to_string(),
        };

        let ctx = session.context_window;
        let used = session.total_estimated_tokens;
        let pct = (used * 100).checked_div(ctx).unwrap_or(0);

        // TODO(cost-tracking): `session.total_cost` is always 0.0
        // because dirge doesn't yet have a per-provider pricing
        // table — `AgentEvent::Done` emits `cost: 0.0` unconditionally
        // (see `src/agent/runner.rs::run_stream`). Until that's wired,
        // the cost segment is suppressed entirely to avoid showing a
        // misleading "$0.0000". When pricing lands, restore the
        // conditional formatter that was here previously.
        let cost_str = String::new();

        let compact_badge = if session.compactions.is_empty() {
            String::new()
        } else {
            format!(" cmp:{}", session.compactions.len())
        };

        let loop_badge = match loop_label {
            Some(label) => format!(" [{}]", label),
            None => String::new(),
        };

        let prompt_badge = match prompt_name {
            Some(name) => format!(" [{}]", name),
            None => String::new(),
        };

        let perm_badge = match perm_mode {
            Some(m) if m != "standard" => format!(" | mode:{}", m),
            _ => String::new(),
        };

        // Active background work, counted per kind. Each badge is shown
        // only when non-zero, like the other conditional badges, so the
        // bar stays quiet during normal single-agent work.
        let active_agents = bg_store.map(|s| s.running_count()).unwrap_or(0);
        let active_shells = shell_store.map(|s| s.running_count()).unwrap_or(0);
        let agents_badge = if active_agents > 0 {
            format!(" | agents:{}", active_agents)
        } else {
            String::new()
        };
        let shells_badge = if active_shells > 0 {
            format!(" | shells:{}", active_shells)
        } else {
            String::new()
        };

        let sandbox_badge_str = match sandbox_badge {
            Some(label) => format!(" | sbx:{}", label),
            None => String::new(),
        };

        let session_badge = format!(" session:{}", crate::text::short_id(session.id.as_str()));

        format!(
            "{}{} | {}{} | {}/{} ({}%) | {}msgs | {}{}{}{}{}{}{}{}",
            project_label,
            cost_str,
            session.model,
            loop_badge,
            fmt_tokens(used),
            fmt_tokens(ctx),
            pct,
            session.messages.len(),
            state,
            compact_badge,
            sandbox_badge_str,
            prompt_badge,
            perm_badge,
            agents_badge,
            shells_badge,
            session_badge,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::{StatusLine, cached_git_branch, git_branch};
    use crate::agent::tools::background::BackgroundStore;
    use crate::agent::tools::bg_shell::BackgroundShellStore;
    use crate::session::Session;
    use std::path::Path;

    /// The cache returns the same branch as the direct lookup (whatever it is —
    /// a branch name, or `None` under a detached HEAD in CI), and a second
    /// call within the TTL returns the same value (cache hit) [dirge-vuzz].
    #[test]
    fn cached_git_branch_matches_direct_and_caches() {
        let p = Path::new(".");
        let direct = git_branch(p);
        let cached = cached_git_branch(p);
        assert_eq!(direct, cached);
        assert_eq!(cached_git_branch(p), cached);
    }

    /// A subagent store with `agents` running subagents (each needs a
    /// live handle to count, so attach a never-ending spawned task).
    fn agent_store(agents: usize) -> BackgroundStore {
        let store = BackgroundStore::new();
        for n in 0..agents {
            let id = format!("a{n}");
            store.insert(id.clone());
            if tokio::runtime::Handle::try_current().is_ok() {
                store.attach_handle(&id, tokio::spawn(std::future::pending::<()>()));
            }
        }
        store
    }

    /// A shell store with `shells` running shells.
    fn shell_store(shells: usize) -> BackgroundShellStore {
        let store = BackgroundShellStore::new();
        for n in 0..shells {
            store.register(format!("s{n}"), "cmd".to_string());
        }
        store
    }

    fn render(agents: usize, shells: usize) -> String {
        let session = Session::new("openrouter", "test-model", 100_000);
        let a = agent_store(agents);
        let s = shell_store(shells);
        StatusLine::render(
            &session,
            false,
            0,
            None,
            None,
            None,
            Some(&a),
            Some(&s),
            None,
        )
    }

    #[tokio::test]
    async fn badges_hidden_when_nothing_active() {
        let line = render(0, 0);
        assert!(
            !line.contains("agents:"),
            "no agents badge expected: {line}"
        );
        assert!(
            !line.contains("shells:"),
            "no shells badge expected: {line}"
        );
    }

    #[tokio::test]
    async fn agents_and_shells_counted_separately() {
        let line = render(2, 3);
        assert!(line.contains("agents:2"), "expected agents:2 in: {line}");
        assert!(line.contains("shells:3"), "expected shells:3 in: {line}");
    }

    /// The session id is shown at the end of the status line so the
    /// user can copy it for `--session <id>` resume from the banner.
    #[tokio::test]
    async fn session_id_appears_in_status_line() {
        let session = Session::new("openrouter", "test-model", 100_000);
        let line = StatusLine::render(&session, false, 0, None, None, None, None, None, None);
        let expected = format!(" session:{}", crate::text::short_id(session.id.as_str()));
        assert!(line.contains(&expected), "session id not in status: {line}");
    }
}