collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use crate::tui::state::TokenStats;
use crate::tui::theme::Theme;

type CheckpointManager = crate::agent::checkpoint::CheckpointManager;

pub(super) fn help_text() -> String {
    r#"## Available Commands

/init           Analyze project and generate AGENTS.md
/config         Edit configuration settings interactively
/help           Show this help message
/clear          Clear conversation and reset context
/compact        Force context compaction
/cost           Show token usage statistics
/diff           Show uncommitted git changes
/sdiff          Side-by-side diff view
/undo           Revert the last git commit
/models [name]  Open model picker or switch directly
/rewind [id]    Rewind to last checkpoint
/checkpoints    List available checkpoints
/search <q>     BM25 relevance search across codebase
/web <url>      Fetch URL content and display
/skills         List and select available skills
/agents         List and switch configured agents
/mcp            Show MCP server configuration
/fork           Toggle fork mode for this session (coordinator splits → parallel → merge)
/hive           Toggle hive mode for this session (peer consensus, coordinator monitors)
/flock          Toggle flock mode for this session (real-time inter-agent messaging)
/collab-status  Show collaboration mode status and settings
/monitor        Toggle debug monitor in sidebar
/bench          Open bench dashboard (performance analytics)
/theme          Show or change color theme
/save-plan      Save architect plan to a file
/proceed        Start code implementation from plan
/cancel-plan    Discard pending architect plan
/resume         Resume an incomplete session
/quit           Exit collet

## @ Mentions

• @model-name     Switch agent (e.g. @glm-5 fix the bug)
• @path/to/file   Attach file contents as context
• @folder         Attach folder tree listing

## Key Bindings

Enter           Send message
Shift+Enter     New line (multiline input)
↑/↓             Input history
Shift+↑/↓       Scroll output
PgUp/PgDn       Scroll output (fast)
Ctrl+C          Cancel agent / Clear input / Quit
Ctrl+A/E        Move cursor to line start/end
Ctrl+W          Delete word backward
Ctrl+U/K        Delete to line start/end
Alt+←/→         Word skip (left/right)
Shift+Drag      Select text for copy (OS native)"#
        .to_string()
}

pub(super) fn cost_text(stats: &TokenStats) -> String {
    format!(
        "## Token Usage\n\n\
         Prompt tokens       {}\n\
         Completion tokens   {}\n\
         Total tokens        {}\n\
         API calls           {}\n\
         Est. context used   ~{}%",
        stats.prompt_tokens,
        stats.completion_tokens,
        stats.total_tokens(),
        stats.api_calls,
        (stats.total_tokens() as f64 / 1200.0).min(100.0) as u32,
    )
}

pub(super) fn diff_text(working_dir: &str) -> String {
    match std::process::Command::new("git")
        .args(["diff", "--stat", "--no-color"])
        .current_dir(working_dir)
        .output()
    {
        Ok(output) => {
            let stat = String::from_utf8_lossy(&output.stdout);
            if stat.trim().is_empty() {
                "No uncommitted changes.".to_string()
            } else {
                // Also get the actual diff (truncated)
                let diff_output = std::process::Command::new("git")
                    .args(["diff", "--no-color"])
                    .current_dir(working_dir)
                    .output()
                    .ok();

                let diff = diff_output
                    .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
                    .unwrap_or_default();

                let diff_preview = if diff.len() > 2000 {
                    format!(
                        "{}...\n\n(truncated, {} total chars)",
                        crate::util::truncate_bytes(&diff, 2000),
                        diff.len()
                    )
                } else {
                    diff
                };

                format!(
                    "## Summary\n\n{}\n\n## Changes\n\n{}",
                    stat.trim(),
                    diff_preview,
                )
            }
        }
        Err(e) => format!("Failed to run git diff: {e}"),
    }
}

pub(super) fn side_by_side_diff_text(working_dir: &str) -> String {
    use crate::tui::widgets::diff_view::FileDiff;

    // Get list of changed files.
    let name_output = match std::process::Command::new("git")
        .args(["diff", "--name-only", "--no-color"])
        .current_dir(working_dir)
        .output()
    {
        Ok(o) => o,
        Err(e) => return format!("Failed to run git diff: {e}"),
    };

    let names = String::from_utf8_lossy(&name_output.stdout);
    let files: Vec<&str> = names.lines().filter(|l| !l.trim().is_empty()).collect();

    if files.is_empty() {
        return "No uncommitted changes.".to_string();
    }

    let mut diffs: Vec<FileDiff> = Vec::new();

    for file in &files {
        // Get old content (from HEAD).
        let old_content = std::process::Command::new("git")
            .args(["show", &format!("HEAD:{file}")])
            .current_dir(working_dir)
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
            .unwrap_or_default();

        // Get new content (working tree).
        let file_path = std::path::Path::new(working_dir).join(file);
        let new_content = std::fs::read_to_string(&file_path).unwrap_or_default();

        diffs.push(FileDiff::from_strings(file, &old_content, &new_content));
    }

    // Serialize diffs as a special marker so the TUI can detect and render them.
    // For now, fall back to a textual representation.
    let mut output = String::new();
    // Recompute each diff to ensure hunk consistency with stored content.
    let diffs: Vec<_> = diffs.iter().map(|d| d.recompute()).collect();
    for diff in &diffs {
        output.push_str(&format!("### {}\n\n", diff.path));
        if diff.hunks.is_empty() {
            output.push_str("(no changes)\n\n");
            continue;
        }
        for hunk in &diff.hunks {
            output.push_str(&format!("@@ -{} +{} @@\n", hunk.old_start, hunk.new_start));
            for (left, right) in &hunk.lines {
                let l_no = left
                    .line_no
                    .map(|n| format!("{n:>4}"))
                    .unwrap_or_else(|| "    ".to_string());
                let r_no = right
                    .line_no
                    .map(|n| format!("{n:>4}"))
                    .unwrap_or_else(|| "    ".to_string());
                let l_marker = match left.kind {
                    crate::tui::widgets::diff_view::DiffKind::Removed => "-",
                    crate::tui::widgets::diff_view::DiffKind::Added => "+",
                    _ => " ",
                };
                let r_marker = match right.kind {
                    crate::tui::widgets::diff_view::DiffKind::Removed => "-",
                    crate::tui::widgets::diff_view::DiffKind::Added => "+",
                    _ => " ",
                };
                output.push_str(&format!(
                    "{l_no}{l_marker} {:<40} │ {r_no}{r_marker} {}\n",
                    left.content, right.content,
                ));
            }
            output.push('\n');
        }
    }
    output
}

pub(super) fn undo_text(working_dir: &str) -> String {
    // Check if there are commits to undo
    let log = std::process::Command::new("git")
        .args(["log", "--oneline", "-1"])
        .current_dir(working_dir)
        .output();

    match log {
        Ok(output) => {
            let last_commit = String::from_utf8_lossy(&output.stdout);
            if last_commit.trim().is_empty() {
                return "No commits to undo.".to_string();
            }

            // Perform the revert
            match std::process::Command::new("git")
                .args(["revert", "--no-edit", "HEAD"])
                .current_dir(working_dir)
                .output()
            {
                Ok(revert) => {
                    if revert.status.success() {
                        format!(
                            "Reverted: `{}`\nA new revert commit was created.",
                            last_commit.trim(),
                        )
                    } else {
                        let stderr = String::from_utf8_lossy(&revert.stderr);
                        // Try reset instead if revert fails (e.g., merge conflicts)
                        format!(
                            "Revert failed: {}\nYou can try `git reset --soft HEAD~1` manually.",
                            stderr.trim(),
                        )
                    }
                }
                Err(e) => format!("Failed to run git revert: {e}"),
            }
        }
        Err(e) => format!("Failed to run git log: {e}"),
    }
}

pub(super) fn theme_text(args: &str) -> String {
    if args.is_empty() {
        let themes: Vec<String> = Theme::available()
            .iter()
            .enumerate()
            .map(|(i, name)| format!("  {}. {}", i + 1, name))
            .collect();
        format!(
            "# Color Themes\n\n{}\n\n---\nApply: /theme <name>   e.g. /theme dracula",
            themes.join("\n")
        )
    } else {
        let name = args.trim();
        if Theme::available().contains(&name) {
            format!("THEME_CHANGE:{name}")
        } else {
            let available = Theme::available().join(", ");
            format!("Unknown theme: `{name}`\nAvailable: {available}")
        }
    }
}

pub(super) fn web_text(args: &str) -> String {
    if args.is_empty() {
        return "Usage: `/web <url>` — fetch a URL and display its content.".to_string();
    }

    let url = args.trim();
    if !url.starts_with("http://") && !url.starts_with("https://") {
        return format!("Invalid URL: `{url}`. Must start with http:// or https://");
    }

    // Use a blocking HTTP request (commands run synchronously)
    match std::process::Command::new("curl")
        .args([
            "-sL",
            "--max-time",
            "10",
            "-H",
            "User-Agent: collet/0.5",
            url,
        ])
        .output()
    {
        Ok(output) => {
            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                return format!("Failed to fetch URL: {}", stderr.trim());
            }

            let body = String::from_utf8_lossy(&output.stdout);

            // Strip HTML tags for readability (basic)
            let text = strip_html_tags(&body);

            // Truncate
            let preview = if text.len() > 5000 {
                format!(
                    "{}...\n\n(truncated, {} total chars)",
                    crate::util::truncate_bytes(&text, 5000),
                    text.len()
                )
            } else {
                text
            };

            format!("## Web: {url}\n\n```\n{preview}\n```")
        }
        Err(e) => format!("Failed to run curl: {e}. Is curl installed?"),
    }
}

/// Basic HTML tag stripper for /web command output.
pub(super) fn strip_html_tags(html: &str) -> String {
    let mut result = String::with_capacity(html.len());
    let mut in_tag = false;
    let mut in_script = false;
    let mut in_style = false;

    let lower = html.to_lowercase();
    let chars: Vec<char> = html.chars().collect();
    let lower_chars: Vec<char> = lower.chars().collect();

    let mut i = 0;
    while i < chars.len() {
        if !in_tag && chars[i] == '<' {
            in_tag = true;
            // Check for script/style tags
            let remaining: String = lower_chars[i..].iter().take(20).collect();
            if remaining.starts_with("<script") {
                in_script = true;
            } else if remaining.starts_with("</script") {
                in_script = false;
            } else if remaining.starts_with("<style") {
                in_style = true;
            } else if remaining.starts_with("</style") {
                in_style = false;
            }
        } else if in_tag && chars[i] == '>' {
            in_tag = false;
        } else if !in_tag && !in_script && !in_style {
            result.push(chars[i]);
        }
        i += 1;
    }

    // Clean up excessive whitespace
    let mut cleaned = String::new();
    let mut last_was_newline = false;
    for line in result.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            if !last_was_newline {
                cleaned.push('\n');
                last_was_newline = true;
            }
        } else {
            cleaned.push_str(trimmed);
            cleaned.push('\n');
            last_was_newline = false;
        }
    }

    cleaned
}

pub(super) fn checkpoints_text(mgr: &CheckpointManager) -> String {
    let checkpoints = mgr.list();
    if checkpoints.is_empty() {
        return "No checkpoints available.\n\nCheckpoints are created when the agent modifies files."
            .to_string();
    }

    let mut lines = vec!["## Checkpoints\n".to_string()];
    for (id, _timestamp, file_count, msg) in &checkpoints {
        let preview = if msg.len() > 60 {
            format!("{}...", crate::util::truncate_bytes(msg, 60))
        } else {
            msg.to_string()
        };
        lines.push(format!("  #{id:<4}  {file_count} files   {preview}"));
    }

    lines.push(
        "\n/rewind       restore most recent\n/rewind <id>  restore specific checkpoint"
            .to_string(),
    );
    lines.join("\n")
}