use colored::Colorize;
use std::io;
use std::io::Write;
use std::sync::OnceLock;
pub const MAX_TOOL_OUTPUT_TOKENS: usize = 16_000;
pub const MAX_FILE_READ_TOKENS: usize = 64_000;
pub const MAX_PATH_LIST_TOKENS: usize = 250_000;
pub const MAX_DIFF_TOKENS: usize = 250_000;
pub const MAX_MCP_OUTPUT_TOKENS: usize = 250_000;
pub const MAX_MCP_IMAGE_COUNT: usize = 10;
pub const MAX_MCP_IMAGE_BYTES: usize = 20 * 1024 * 1024;
pub fn is_absolute_path(path: &str) -> bool {
path.starts_with('/') || std::path::Path::new(path).is_absolute()
}
pub fn is_absolute_or_tilde(path: &str) -> bool {
path.starts_with('~') || is_absolute_path(path)
}
#[derive(Copy, Clone, Debug)]
pub enum TruncationKind {
File,
BashOutput,
SearchOutput,
PathList,
DiffOutput,
McpOutput,
}
impl TruncationKind {
fn subject_and_hint(&self) -> (&'static str, &'static str) {
match self {
Self::File => (
"File",
"Use search_code or request specific line ranges if you need more.",
),
Self::BashOutput => (
"Output",
"Re-run with output redirection if you need the full output.",
),
Self::SearchOutput => (
"Search output",
"Narrow the pattern, add a file_type filter, or lower max_results to reduce the output.",
),
Self::PathList => (
"Path list",
"Narrow the glob pattern or list a smaller subdirectory to reduce the output.",
),
Self::DiffOutput => (
"Diff",
"The edit already succeeded — use read_file with a line range to inspect specific regions if needed.",
),
Self::McpOutput => (
"MCP response",
"The MCP tool response was capped before being returned to the model. Narrow the query, request a specific subset, or call the tool with a tighter scope if you need more.",
),
}
}
fn suffix(&self, estimated_tokens: usize, shown_tokens: usize) -> String {
let (subject, hint) = self.subject_and_hint();
format!(
"[TRUNCATED: {} has ~{} tokens, showing first ~{} tokens. {}]",
subject, estimated_tokens, shown_tokens, hint
)
}
}
pub fn truncate_for_context(content: &str, max_tokens: usize, kind: TruncationKind) -> String {
let estimated_tokens = content.len() / 4;
if estimated_tokens > max_tokens {
let truncate_at = crate::api::utils::truncate_at_char_boundary(content, max_tokens * 4);
let truncated_content = &content[..truncate_at];
format!(
"{}...\n\n{}",
truncated_content,
kind.suffix(estimated_tokens, max_tokens)
)
} else {
content.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConfirmationType {
Destructive,
Permission,
#[allow(dead_code)]
Info,
}
pub type ConfirmHandler =
Box<dyn Fn(&str, &[String], usize, ConfirmationType) -> usize + Send + Sync>;
static CONFIRM_HANDLER: OnceLock<ConfirmHandler> = OnceLock::new();
pub fn set_confirm_handler(handler: ConfirmHandler) -> bool {
CONFIRM_HANDLER.set(handler).is_ok()
}
impl ConfirmationType {
fn icon(&self) -> &'static str {
match self {
Self::Destructive => "🗑️ ",
Self::Permission => "🔐",
Self::Info => "❓",
}
}
fn prompt_style(&self) -> colored::ColoredString {
match self {
Self::Destructive => "Confirm".truecolor(0xFF, 0x99, 0x33).bold(), Self::Permission => "Permission".bright_yellow().bold(),
Self::Info => "Confirm".bright_cyan().bold(),
}
}
}
pub fn confirm_multi_choice(
prompt: &str,
choices: &[&str],
default_index: usize,
confirmation_type: ConfirmationType,
) -> crate::error::Result<usize> {
if choices.is_empty() {
return Err(crate::error::SofosError::Config(
"confirm_multi_choice requires at least one choice".to_string(),
));
}
let default_index = default_index.min(choices.len() - 1);
if let Some(handler) = CONFIRM_HANDLER.get() {
let choices_owned: Vec<String> = choices.iter().map(|s| s.to_string()).collect();
let selected = handler(prompt, &choices_owned, default_index, confirmation_type);
return Ok(selected.min(choices.len() - 1));
}
eprintln!();
eprintln!(
"{} {}: {}",
confirmation_type.icon(),
confirmation_type.prompt_style(),
prompt
);
for (i, choice) in choices.iter().enumerate() {
let marker = if i == default_index { "*" } else { " " };
eprintln!(" {} [{}] {}", marker.dimmed(), i + 1, choice);
}
eprint!(
" {} ",
format!(
"Choose 1–{} (default {}): ",
choices.len(),
default_index + 1
)
.dimmed()
);
io::stderr().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_index);
}
match trimmed.parse::<usize>() {
Ok(n) if n >= 1 && n <= choices.len() => Ok(n - 1),
_ => Ok(default_index),
}
}
pub fn confirm_destructive(prompt: &str) -> crate::error::Result<bool> {
let idx = confirm_multi_choice(prompt, &["Yes", "No"], 1, ConfirmationType::Destructive)?;
Ok(idx == 0)
}
pub fn html_to_text(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let mut in_tag = false;
let mut in_script = false;
let mut in_style = false;
let mut last_was_whitespace = false;
let lower = html.to_lowercase();
let chars: Vec<char> = html.chars().collect();
let lower_chars: Vec<char> = lower.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if in_tag {
if chars[i] == '>' {
in_tag = false;
}
i += 1;
continue;
}
if chars[i] == '<' {
let rest = &lower[lower.char_indices().nth(i).map_or(0, |(idx, _)| idx)..];
if rest.starts_with("<script") {
in_script = true;
} else if rest.starts_with("</script") {
in_script = false;
} else if rest.starts_with("<style") {
in_style = true;
} else if rest.starts_with("</style") {
in_style = false;
}
let is_block = rest.starts_with("<br")
|| rest.starts_with("<p")
|| rest.starts_with("</p")
|| rest.starts_with("<div")
|| rest.starts_with("</div")
|| rest.starts_with("<li")
|| rest.starts_with("<h1")
|| rest.starts_with("<h2")
|| rest.starts_with("<h3")
|| rest.starts_with("<h4")
|| rest.starts_with("<tr")
|| rest.starts_with("</tr");
if is_block && !out.ends_with('\n') {
out.push('\n');
last_was_whitespace = true;
}
in_tag = true;
i += 1;
continue;
}
if in_script || in_style {
i += 1;
continue;
}
if chars[i] == '&' {
let rest: String = lower_chars[i..].iter().take(10).collect();
if rest.starts_with("&") {
out.push('&');
i += 5;
} else if rest.starts_with("<") {
out.push('<');
i += 4;
} else if rest.starts_with(">") {
out.push('>');
i += 4;
} else if rest.starts_with(""") {
out.push('"');
i += 6;
} else if rest.starts_with("'") || rest.starts_with("'") {
out.push('\'');
i += if rest.starts_with("'") { 5 } else { 6 };
} else if rest.starts_with(" ") {
out.push(' ');
i += 6;
} else {
out.push('&');
i += 1;
}
last_was_whitespace = false;
continue;
}
let ch = chars[i];
if ch.is_whitespace() {
if !last_was_whitespace {
out.push(if ch == '\n' { '\n' } else { ' ' });
last_was_whitespace = true;
}
} else {
out.push(ch);
last_was_whitespace = false;
}
i += 1;
}
let mut result = String::new();
let mut consecutive_newlines = 0;
for ch in out.chars() {
if ch == '\n' {
consecutive_newlines += 1;
if consecutive_newlines <= 2 {
result.push(ch);
}
} else {
consecutive_newlines = 0;
result.push(ch);
}
}
result.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_for_context_preserves_short_content() {
let short = "tiny";
assert_eq!(
truncate_for_context(short, 16_000, TruncationKind::File),
short
);
assert_eq!(
truncate_for_context(short, 16_000, TruncationKind::BashOutput),
short
);
}
#[test]
fn truncate_for_context_file_variant_hints_at_range_read() {
let big = "x".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::File);
assert!(out.contains("[TRUNCATED: File has"));
assert!(out.contains("search_code or request specific line ranges"));
assert!(!out.contains("output redirection"));
}
#[test]
fn truncate_for_context_bash_variant_hints_at_redirection() {
let big = "y".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::BashOutput);
assert!(out.contains("[TRUNCATED: Output has"));
assert!(out.contains("output redirection"));
assert!(!out.contains("search_code"));
}
#[test]
fn truncate_for_context_search_variant_hints_at_narrowing() {
let big = "z".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::SearchOutput);
assert!(out.contains("[TRUNCATED: Search output has"));
assert!(out.contains("Narrow the pattern"));
assert!(out.contains("file_type"));
assert!(out.contains("max_results"));
assert!(!out.contains("output redirection"));
}
#[test]
fn truncate_for_context_path_list_variant_hints_at_subdirectory() {
let big = "p".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::PathList);
assert!(out.contains("[TRUNCATED: Path list has"));
assert!(out.contains("Narrow the glob pattern"));
assert!(out.contains("subdirectory"));
assert!(!out.contains("file_type"));
}
#[test]
fn truncate_for_context_handles_multibyte_boundary() {
let max_tokens = 4;
let cut = max_tokens * 4; let mut s = "a".repeat(cut - 1);
s.push('ъ');
s.push_str(" and some trailing context to push past the limit");
assert!(
!s.is_char_boundary(cut),
"test setup: byte {} must be inside a multi-byte char",
cut
);
let out = truncate_for_context(&s, max_tokens, TruncationKind::File);
assert!(out.contains("[TRUNCATED"));
}
#[test]
fn truncate_for_context_diff_variant_points_at_read_file() {
let big = "d".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::DiffOutput);
assert!(out.contains("[TRUNCATED: Diff has"));
assert!(out.contains("edit already succeeded"));
assert!(out.contains("read_file"));
assert!(!out.contains("glob pattern"));
}
#[test]
fn truncate_for_context_mcp_variant_mentions_server() {
let big = "m".repeat(20_000);
let out = truncate_for_context(&big, 4, TruncationKind::McpOutput);
assert!(out.contains("[TRUNCATED: MCP response has"));
assert!(out.contains("MCP tool response was capped"));
assert!(!out.contains("glob pattern"));
assert!(!out.contains("edit already succeeded"));
}
#[test]
fn is_absolute_path_catches_unix_style_paths_on_every_platform() {
assert!(is_absolute_path("/"));
assert!(is_absolute_path("/etc/hosts"));
assert!(is_absolute_path("/tmp/foo"));
assert!(is_absolute_path("//double-slash"));
assert!(!is_absolute_path("~"));
assert!(!is_absolute_path("~/foo"));
assert!(!is_absolute_path(""));
assert!(!is_absolute_path("foo"));
assert!(!is_absolute_path("./foo"));
assert!(!is_absolute_path("../foo"));
#[cfg(windows)]
{
assert!(is_absolute_path(r"C:\foo"));
assert!(is_absolute_path(r"D:\Users\me"));
assert!(is_absolute_path(r"\\server\share\file"));
assert!(!is_absolute_path(r"C:foo"));
assert!(!is_absolute_path(r"foo\bar"));
}
}
#[test]
fn is_absolute_or_tilde_classifies_all_platform_shapes() {
assert!(is_absolute_or_tilde("~"));
assert!(is_absolute_or_tilde("~/foo"));
assert!(is_absolute_or_tilde("~/foo/bar.txt"));
assert!(is_absolute_or_tilde("/"));
assert!(is_absolute_or_tilde("/etc/hosts"));
assert!(is_absolute_or_tilde("/tmp/foo"));
assert!(!is_absolute_or_tilde(""));
assert!(!is_absolute_or_tilde("foo"));
assert!(!is_absolute_or_tilde("foo/bar.txt"));
assert!(!is_absolute_or_tilde("./foo"));
assert!(!is_absolute_or_tilde("../foo"));
assert!(!is_absolute_or_tilde("foo~bar"));
assert!(!is_absolute_or_tilde("src/~tmp"));
#[cfg(windows)]
{
assert!(is_absolute_or_tilde(r"C:\foo"));
assert!(is_absolute_or_tilde(r"C:\Users\me\doc.txt"));
assert!(is_absolute_or_tilde(r"\\server\share\file"));
assert!(!is_absolute_or_tilde(r"foo\bar"));
}
}
}