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 {
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;
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 {
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();
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));
}
let mut output = String::new();
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 {
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();
}
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);
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://");
}
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);
let text = strip_html_tags(&body);
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?"),
}
}
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;
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;
}
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")
}