use serde_json::Value;
use crate::agent::protocol::DialogMethod;
use crate::agent::protocol::DialogRequest;
use crate::chat::chars::{PrefixKey, prefixed};
use crate::session::event::Delegated;
use crate::session::files::Entry;
use crate::session::files::{FileContents, MAX_INLINE_BYTES};
pub const MESSAGE_LIMIT: usize = 2000;
pub const THREAD_NAME_LIMIT: usize = 100;
const FENCE: &str = "```";
const CLOSING_COST: usize = FENCE.len() + 1;
const FENCE_HEADROOM: usize = 16;
pub const MAX_LISTED_ENTRIES: usize = 200;
fn split_by_code_points(text: &str, size: usize) -> Vec<String> {
let points: Vec<char> = text.chars().collect();
if points.len() <= size {
return vec![text.to_owned()];
}
points
.chunks(size)
.map(|chunk| chunk.iter().collect())
.collect()
}
fn length(text: &str) -> usize {
text.chars().count()
}
fn fence_language(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if !trimmed.starts_with(FENCE) {
return None;
}
Some(trimmed[FENCE.len()..].trim().to_owned())
}
pub fn split_message(text: &str, limit: usize) -> Vec<String> {
if length(text) <= limit {
return if text.is_empty() {
Vec::new()
} else {
vec![text.to_owned()]
};
}
let mut lines = Vec::new();
for line in text.split('\n') {
lines.extend(split_by_code_points(line, limit - FENCE_HEADROOM));
}
let mut chunks: Vec<String> = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut current_length = 0;
let mut open_language: Option<String> = None;
for line in &lines {
let reserve = if open_language.is_some() {
CLOSING_COST
} else {
0
};
let cost = if current.is_empty() {
length(line)
} else {
length(line) + 1
};
if current_length + cost + reserve > limit {
flush(
&mut chunks,
&mut current,
&mut current_length,
open_language.as_ref(),
);
}
let first_in_chunk = current.is_empty();
current.push(line.clone());
current_length += if first_in_chunk {
length(line)
} else {
length(line) + 1
};
if let Some(language) = fence_language(line) {
open_language = if open_language.is_none() {
Some(language)
} else {
None
};
}
}
if !current.is_empty() {
chunks.push(current.join("\n"));
}
chunks
.into_iter()
.filter(|chunk| !chunk.is_empty())
.collect()
}
fn flush(
chunks: &mut Vec<String>,
current: &mut Vec<String>,
current_length: &mut usize,
open_language: Option<&String>,
) {
if current.is_empty() {
return;
}
let mut body = current.join("\n");
if open_language.is_some() {
body.push('\n');
body.push_str(FENCE);
}
chunks.push(body);
current.clear();
*current_length = 0;
if let Some(language) = open_language {
let reopened = format!("{FENCE}{language}");
*current_length = length(&reopened);
current.push(reopened);
}
}
pub fn thread_name(project: &str, prompt: &str) -> String {
let first_line = prompt
.split('\n')
.find(|line| !line.trim().is_empty())
.unwrap_or("session");
let collapsed = collapse_spaces(first_line.trim());
let prefix = format!("{project}: ");
let room = THREAD_NAME_LIMIT - length(&prefix);
let mut body: String = collapsed.chars().take(room).collect();
while body.ends_with(|c: char| c.is_whitespace()) {
body.pop();
}
if body.is_empty() {
"session".clone_into(&mut body);
}
format!("{prefix}{body}")
}
fn collapse_spaces(text: &str) -> String {
let mut collapsed = String::with_capacity(text.len());
let mut previous_was_space = false;
for character in text.chars() {
if character.is_whitespace() {
if !previous_was_space {
collapsed.push(' ');
}
previous_was_space = true;
} else {
collapsed.push(character);
previous_was_space = false;
}
}
collapsed.trim_end().to_owned()
}
pub fn truncate(text: &str, max: usize) -> String {
let points: Vec<char> = text.chars().collect();
if points.len() <= max {
return text.to_owned();
}
let kept: String = points[..max].iter().collect();
format!(
"{kept}\n[truncated, {} more characters]",
points.len() - max
)
}
pub fn tool_line(tool_name: &str, target: Option<&str>) -> String {
let Some(target) = target else {
return prefixed(PrefixKey::Tool, &format!("`{tool_name}`"));
};
if target.trim().is_empty() {
return prefixed(PrefixKey::Tool, &format!("`{tool_name}`"));
}
let flattened = collapse_spaces(target.trim());
let flattened = flattened.replace('`', "'");
prefixed(PrefixKey::Tool, &format!("`{tool_name}` `{flattened}`"))
}
pub fn usage_summary(usage: &Usage) -> String {
let sent = usage.input + usage.cache_read;
#[expect(clippy::cast_possible_truncation)]
let cached = if sent == 0.0 {
0
} else {
((usage.cache_read / sent) * 100.0).round() as i64
};
let mut parts = vec![
format!("{} tokens", tokens(usage.total_tokens)),
format!("{cached}% cached"),
];
if usage.context_tokens > 0.0 {
parts.push(if usage.context_window <= 0.0 {
format!("{} context", tokens(usage.context_tokens))
} else {
#[expect(clippy::cast_possible_truncation)]
let share = ((usage.context_tokens / usage.context_window) * 100.0).round() as i64;
format!(
"{}/{} context ({share}%)",
tokens(usage.context_tokens),
tokens(usage.context_window),
)
});
}
if usage.cost > 0.0 {
parts.push(format!("${:.4}", usage.cost));
}
parts.join(", ")
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Usage {
pub input: f64,
pub cache_read: f64,
pub total_tokens: f64,
pub cost: f64,
pub context_tokens: f64,
pub context_window: f64,
}
const MAGNITUDES: [(f64, &str); 3] = [(1_000_000_000.0, "B"), (1_000_000.0, "M"), (1_000.0, "k")];
pub fn tokens(count: f64) -> String {
let size = count.abs();
for (index, (magnitude, suffix)) in MAGNITUDES.iter().enumerate() {
if size < *magnitude {
continue;
}
let scaled = count / magnitude;
let digits = usize::from(scaled.abs() < 100.0);
#[expect(
clippy::uninlined_format_args,
reason = "rounding can push a value into the next magnitude: 999,999 would read as 1000k, which is a magnitude out. Carry it up instead"
)]
let rounded = format!("{scaled:.digits$}", digits = digits);
if rounded
.trim_start_matches('-')
.parse::<f64>()
.is_ok_and(|value| value.abs() >= 1000.0)
&& index > 0
{
let (bigger, bigger_suffix) = MAGNITUDES[index - 1];
return format!("{:.1}{}", count / bigger, bigger_suffix);
}
#[expect(clippy::uninlined_format_args)]
return format!("{scaled:.digits$}{suffix}", digits = digits);
}
format!("{count}")
}
pub fn when_relative(epoch_ms: i64) -> String {
format!("<t:{}:R>", epoch_ms.div_euclid(1000))
}
pub fn when_relative_plain(epoch_ms: i64, now: i64) -> String {
#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
let minutes = ((epoch_ms - now) as f64 / 60_000.0).ceil() as i64;
if minutes <= 0 {
return "now".to_owned();
}
if minutes < 60 {
return format!("in {minutes}m");
}
let hours = minutes / 60;
let rest = minutes % 60;
if rest == 0 {
format!("in {hours}h")
} else {
format!("in {hours}h {rest}m")
}
}
pub fn warning_line(text: &str) -> String {
prefixed(PrefixKey::Warning, text)
}
pub fn connection_line(text: &str) -> String {
prefixed(PrefixKey::Connection, text)
}
pub fn question_line(text: &str) -> String {
prefixed(PrefixKey::Question, text)
}
pub fn marker(state: &str) -> String {
format!("[{state}]")
}
pub fn compaction_line(answer: &Value) -> String {
if answer.get("success") == Some(&Value::Bool(false)) {
let detail = match answer.get("error").and_then(Value::as_str) {
Some(error) => error.to_owned(),
None => "the agent refused".to_owned(),
};
return connection_line(&format!("compaction did not run: {detail}"));
}
let data = answer.get("data").unwrap_or(&Value::Null);
let before = data.get("tokensBefore").and_then(Value::as_f64);
let after = data.get("estimatedTokensAfter").and_then(Value::as_f64);
match (before, after) {
(Some(before), Some(after)) => connection_line(&format!(
"compacted the conversation, about {} tokens down to {}",
tokens(before),
tokens(after)
)),
_ => connection_line("compacted the conversation"),
}
}
pub fn dialog_lines(request: &DialogRequest) -> String {
let mut lines = vec![request.title.clone()];
if let Some(message) = &request.message {
lines.push(message.clone());
}
match (request.method, &request.options) {
(DialogMethod::Select, Some(options)) => {
for (index, option) in options.iter().enumerate() {
lines.push(format!("{}. {option}", index + 1));
}
lines.push("reply with a number or the option text".to_owned());
}
(DialogMethod::Confirm, _) => {
lines.push("reply yes or no".to_owned());
}
_ => lines.push("reply with your answer".to_owned()),
}
lines.join("\n")
}
pub fn delegation_line(delegated: &Delegated) -> String {
if let Some(refused) = &delegated.refused {
return prefixed(
PrefixKey::Warning,
&format!("a delegated question was not asked: {refused}"),
);
}
let saved = match delegated.kept_out {
None | Some(0) => String::new(),
Some(kept_out) => format!(
", keeping {} out of this conversation",
bytes(widen(kept_out as u64))
),
};
prefixed(
PrefixKey::Delegated,
&format!(
"asked {} about {}{saved}",
delegated.model.as_deref().unwrap_or(""),
delegated.describes.as_deref().unwrap_or("")
),
)
}
#[expect(clippy::cast_precision_loss)]
fn widen(count: u64) -> f64 {
count as f64
}
pub fn bytes(count: f64) -> String {
if count < 1000.0 {
return format!("{count} B");
}
let units = ["kB", "MB", "GB", "TB"];
let mut value = count / 1000.0;
let mut unit = 0;
while value >= 1000.0 && unit < units.len() - 1 {
value /= 1000.0;
unit += 1;
}
format!("{value:.1} {}", units[unit])
}
pub fn directory_listing(entries: &[Entry], display_path: &str) -> String {
if entries.is_empty() {
return format!("`{display_path}/` is empty");
}
let shown = &entries[..entries.len().min(MAX_LISTED_ENTRIES)];
let rows: Vec<(String, String)> = shown
.iter()
.map(|entry| {
(
if entry.directory {
format!("{}/", entry.name)
} else {
entry.name.clone()
},
if entry.directory {
String::new()
} else {
bytes(widen(entry.size))
},
)
})
.collect();
let width = rows
.iter()
.map(|(_, size)| size.chars().count())
.max()
.unwrap_or(0);
let body = rows
.iter()
.map(|(name, size)| format!("{size:>width$} {name}"))
.collect::<Vec<_>>()
.join("\n");
let more = if entries.len() > shown.len() {
format!("\n... {} more", entries.len() - shown.len())
} else {
String::new()
};
format!(
"`{display_path}/` {} entries\n```\n{body}{more}\n```",
entries.len()
)
}
pub fn file_view(contents: &FileContents) -> String {
let size = bytes(widen(contents.size));
if contents.binary {
return format!(
"`{}` is binary, {size}. Use `!file` to download it.",
contents.path
);
}
let cut = if contents.truncated {
format!(
"\n... cut at {} of {size}, use `!file` for all of it",
bytes(widen(MAX_INLINE_BYTES))
)
} else {
String::new()
};
format!(
"`{path}` {size}\n```{language}\n{text}{cut}\n```",
path = contents.path,
size = size,
language = contents.language,
text = contents.text,
cut = cut,
)
}
#[cfg(test)]
mod tests;