use super::*;
pub(crate) fn pad_to(s: &str, width: usize) -> String {
let vis = a3s_tui::style::visible_len(s);
if vis >= width {
s.to_string()
} else {
format!("{s}{}", " ".repeat(width - vis))
}
}
pub(crate) fn user_bubble(content: &str, width: usize) -> String {
let lines = content.lines().collect::<Vec<_>>();
if lines.is_empty() {
return String::new();
}
let bubble_width = width.saturating_sub(PAD).max(PAD + 8);
let theme = agent_chrome_theme();
let chrome = agent_chrome(&theme);
chrome
.gutter(lines.join("\n"))
.margin(PAD)
.marker(" ●")
.gap(" ")
.width(bubble_width)
.background_color(SURFACE_SOFT)
.view()
}
pub(crate) fn gutter(color: Color, content: &str) -> String {
let lines = content.lines().collect::<Vec<_>>();
if lines.is_empty() {
return String::new();
}
a3s_tui::components::GutterBlock::lines(lines)
.margin(PAD)
.marker_color(color)
.view()
}
fn input_chrome_width(width: usize) -> u16 {
width.saturating_sub(PAD).min(u16::MAX as usize) as u16
}
pub(crate) fn input_rule(width: usize, color: Color) -> String {
if width == 0 {
return String::new();
}
let theme = agent_chrome_theme();
let chrome = agent_chrome(&theme);
chrome
.input_border()
.margin(PAD)
.rule_color(color)
.view(input_chrome_width(width))
}
pub(crate) fn input_gradient_rule(width: usize, palette: &[Color], offset: usize) -> String {
if width == 0 {
return String::new();
}
let theme = agent_chrome_theme();
let chrome = agent_chrome(&theme);
chrome
.input_border()
.margin(PAD)
.rule('━')
.rainbow(palette.to_vec(), offset)
.view(input_chrome_width(width))
}
pub(crate) fn input_status_rule(
width: usize,
border_color: Color,
context: &str,
label: &str,
) -> String {
if width == 0 {
return String::new();
}
let theme = agent_chrome_theme();
let chrome = agent_chrome(&theme);
chrome
.input_border()
.margin(PAD)
.rule_color(border_color)
.context(context)
.label(label)
.view(input_chrome_width(width))
}
pub(crate) fn input_prompt_line(
prompt: &str,
color: Color,
text: &str,
tint_text: bool,
width: usize,
) -> String {
if width == 0 {
return String::new();
}
let theme = agent_chrome_theme();
let chrome = agent_chrome(&theme);
let mut line = chrome
.prompt(format!("{prompt} "))
.text(text)
.margin(PAD)
.width(width)
.prompt_style(Style::new().fg(color).bold());
if tint_text {
line = line.text_style(Style::new().fg(color));
}
line.view()
}
pub(crate) fn thinking_block(text: &str, width: usize) -> String {
let text = text.trim();
if text.is_empty() || width == 0 {
return String::new();
}
a3s_tui::components::WrappedPrefixBlock::new(text)
.margin(PAD)
.width(width)
.prefixes("💭 ", " ")
.style(Style::new().fg(TN_GRAY).italic())
.view()
}
pub(crate) fn compact_progress_line(elapsed: Duration, width: usize) -> String {
if width == 0 {
return String::new();
}
let pct = ((elapsed.as_secs_f64() / 30.0) * 100.0).min(95.0);
let prefix = Style::new().fg(ACCENT).render(&format!(
" ✦ Compacting context… ({}) ",
fmt_elapsed(elapsed)
));
let progress_width = width
.saturating_sub(a3s_tui::style::visible_len(&prefix))
.clamp(1, 29);
let progress = a3s_tui::components::Progress::new()
.value(pct / 100.0)
.width(progress_width.min(u16::MAX as usize) as u16)
.filled_char('▰')
.empty_char('▱')
.filled_color(ACCENT)
.empty_color(TN_GRAY)
.view();
a3s_tui::style::fit_visible(&format!("{prefix}{progress}"), width)
}
pub(crate) fn wrap_words(text: &str, width: usize) -> Vec<String> {
a3s_tui::style::wrap_words_compact(text, width)
}
pub(crate) fn char_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
pub(crate) fn new_session_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{:016x}-{:x}", nanos, std::process::id())
}
pub(crate) fn fmt_elapsed(d: Duration) -> String {
let s = d.as_secs();
if s >= 3600 {
format!("{}h {:02}m", s / 3600, (s % 3600) / 60)
} else if s >= 60 {
format!("{}m {:02}s", s / 60, s % 60)
} else {
format!("{s}s")
}
}
pub(crate) fn humanize(n: usize) -> String {
if n >= 1000 {
format!("{:.1}k", n as f64 / 1000.0)
} else {
n.to_string()
}
}
pub(crate) fn shimmer(text: &str, phase: usize) -> String {
a3s_tui::components::ShimmerText::new(text)
.phase(phase)
.colors(ACCENT, Color::Rgb(211, 229, 255))
.spread(5.0)
.speed_divisor(3)
.cycle_gap(12)
.view()
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
a3s_tui::style::truncate_visible(s, max)
}
#[cfg(test)]
mod tests {
use super::{
compact_progress_line, gutter, input_gradient_rule, input_prompt_line, input_rule,
input_status_rule, shimmer, thinking_block, truncate, user_bubble, wrap_words,
};
use a3s_tui::style::{strip_ansi, visible_len, Color, Style};
use std::time::Duration;
#[test]
fn wraps_on_word_boundaries_without_splitting_words() {
let lines = wrap_words("the quick brown fox jumps", 9);
assert!(lines.iter().all(|l| l.chars().count() <= 9), "{lines:?}");
assert_eq!(
lines.join(" ").split_whitespace().collect::<Vec<_>>(),
vec!["the", "quick", "brown", "fox", "jumps"]
);
}
#[test]
fn collapses_blank_lines_to_stay_single_spaced() {
let lines = wrap_words("alpha\n\n\nbeta", 40);
assert_eq!(lines, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn hard_breaks_a_word_longer_than_width() {
let lines = wrap_words("supercalifragilistic", 5);
assert!(lines.iter().all(|l| l.chars().count() <= 5), "{lines:?}");
assert_eq!(lines.concat(), "supercalifragilistic");
}
#[test]
fn never_returns_empty_for_blank_input() {
assert_eq!(wrap_words(" ", 10), vec![String::new()]);
}
#[test]
fn wrap_words_counts_display_columns_for_wide_unicode() {
let lines = wrap_words("かなテストあ", 8);
for l in &lines {
assert!(
a3s_tui::style::visible_len(l) <= 8,
"line wider than 8 columns: {l:?}"
);
}
assert_eq!(lines.concat(), "かなテストあ");
}
#[test]
fn wrap_words_uses_shared_compact_width_helper() {
let text = "alpha\n\n中文测试内容 beta";
assert_eq!(
wrap_words(text, 8),
a3s_tui::style::wrap_words_compact(text, 8)
);
}
#[test]
fn truncate_budgets_display_columns_not_chars() {
let out = truncate("アイウエオ", 6);
assert!(
a3s_tui::style::visible_len(&out) <= 6,
"truncated string exceeds 6 columns: {out:?}"
);
assert!(out.ends_with('…'));
assert_eq!(truncate("ok", 6), "ok");
}
#[test]
fn gutter_uses_shared_block_shape() {
let rendered = gutter(Color::Green, "hello\nworld");
let plain = strip_ansi(&rendered);
assert_eq!(plain, " ● hello\n world");
assert!(rendered.contains("\x1b[1;32m●\x1b[0m"));
}
#[test]
fn gutter_preserves_styled_content() {
let styled = Style::new().fg(Color::Yellow).render("styled");
let rendered = gutter(Color::Green, &styled);
assert!(rendered.contains("\x1b[33mstyled\x1b[0m"));
assert_eq!(strip_ansi(&rendered), " ● styled");
}
#[test]
fn gutter_keeps_empty_input_empty() {
assert_eq!(gutter(Color::Green, ""), "");
}
#[test]
fn user_bubble_uses_shared_gutter_block_shape() {
let rendered = user_bubble("hello\nworld", 20);
let plain = strip_ansi(&rendered);
let rows = plain.lines().collect::<Vec<_>>();
assert_eq!(rows, vec![" ● hello ", " world "]);
assert!(rendered.contains("\x1b[38;2;237;237;237;48;2;31;31;31m"));
assert!(rendered.lines().all(|row| visible_len(row) == 18));
}
#[test]
fn user_bubble_keeps_empty_input_empty() {
assert_eq!(user_bubble("", 20), "");
}
#[test]
fn user_bubble_keeps_narrow_body_min_width() {
let rendered = user_bubble("hi", 6);
assert_eq!(strip_ansi(&rendered), " ● hi ");
assert_eq!(visible_len(&rendered), 10);
}
#[test]
fn input_prompt_line_uses_shared_prompt_component() {
let rendered = input_prompt_line("❯", Color::Cyan, "cargo test\n--all", false, 24);
let plain = strip_ansi(&rendered);
let rows = plain.lines().collect::<Vec<_>>();
assert!(rows[0].starts_with(" ❯ cargo test"));
assert!(rows[1].starts_with(" --all"));
assert!(rendered.lines().all(|line| visible_len(line) == 24));
assert!(rendered.contains("\x1b[1;36m❯ \x1b[0m"));
}
#[test]
fn input_prompt_line_can_tint_modal_input_text() {
let rendered = input_prompt_line("?", Color::Cyan, "research mode", true, 28);
assert_eq!(strip_ansi(&rendered).trim_end(), " ? research mode");
assert!(rendered.contains("\x1b[1;36m? \x1b[0m"));
assert!(rendered.contains("\x1b[36mresearch mode\x1b[0m"));
}
#[test]
fn input_rules_use_shared_border_component_widths() {
let plain = strip_ansi(&input_rule(20, Color::BrightBlack));
assert_eq!(visible_len(&plain), 18);
assert!(plain.starts_with(" ─"));
let status = input_status_rule(48, Color::BrightBlack, "70% context used ", "◇ high");
let status_plain = strip_ansi(&status);
assert_eq!(visible_len(&status), 46);
assert!(status_plain.contains("70% context used"));
assert!(status_plain.contains("◇ high"));
assert!(status.contains("\x1b[1;38;2;0;112;243m◇ high\x1b[0m"));
}
#[test]
fn input_gradient_rule_preserves_brand_ribbon_width() {
let rendered = input_gradient_rule(
20,
&[
Color::Rgb(0, 124, 240),
Color::Rgb(0, 223, 216),
Color::Rgb(255, 0, 128),
],
1,
);
let plain = strip_ansi(&rendered);
assert_eq!(visible_len(&rendered), 18);
assert!(plain.starts_with(" ━"));
assert!(rendered.contains("\x1b[1;38;2;"));
}
#[test]
fn thinking_block_uses_shared_wrapped_prefix_block() {
let rendered = thinking_block("alpha beta gamma delta", 16);
let plain = strip_ansi(&rendered);
let rows = plain.lines().collect::<Vec<_>>();
assert!(rows[0].starts_with(" 💭 alpha"));
assert!(rows.iter().skip(1).all(|row| row.starts_with(" ")));
assert!(rendered.lines().all(|line| visible_len(line) == 16));
assert!(rendered.contains("\x1b[3;38;2;143;143;143m💭 alpha"));
}
#[test]
fn thinking_block_keeps_empty_input_empty() {
assert_eq!(thinking_block(" ", 40), "");
assert_eq!(thinking_block("thinking", 0), "");
}
#[test]
fn thinking_block_wraps_wide_unicode_by_display_width() {
let rendered = thinking_block("中文测试内容", 13);
let plain = strip_ansi(&rendered);
let rows = plain.lines().collect::<Vec<_>>();
assert!(rows[0].starts_with(" 💭 中文测试"));
assert!(rows[1].starts_with(" 内容"));
assert!(rendered.lines().all(|line| visible_len(line) == 13));
}
#[test]
fn compact_progress_line_uses_shared_progress_bar() {
let rendered = compact_progress_line(Duration::from_secs(15), 80);
let plain = strip_ansi(&rendered);
assert!(plain.contains("Compacting context"), "{plain}");
assert!(plain.contains("50%"), "{plain}");
assert!(plain.contains('▰'), "{plain}");
assert!(plain.contains('▱'), "{plain}");
assert!(rendered.contains("\x1b[38;2;0;112;243m"));
assert!(visible_len(&rendered) <= 80);
}
#[test]
fn compact_progress_line_caps_estimated_progress() {
let rendered = compact_progress_line(Duration::from_secs(60), 80);
assert!(strip_ansi(&rendered).contains("95%"));
}
#[test]
fn compact_progress_line_stays_bounded_on_narrow_widths() {
let rendered = compact_progress_line(Duration::from_secs(8), 18);
assert_eq!(visible_len(&rendered), 18);
}
#[test]
fn shimmer_uses_shared_component_settings() {
let rendered = shimmer("Working…", 0);
let expected = a3s_tui::components::ShimmerText::new("Working…")
.phase(0)
.colors(Color::Rgb(0, 112, 243), Color::Rgb(211, 229, 255))
.spread(5.0)
.speed_divisor(3)
.cycle_gap(12)
.view();
assert_eq!(rendered, expected);
assert_eq!(strip_ansi(&rendered), "Working…");
assert!(rendered.contains("\x1b[1;38;2;211;229;255mW\x1b[0m"));
}
#[test]
fn shimmer_preserves_complex_glyph_display_width() {
let rendered = shimmer("工作e\u{301}", 0);
assert_eq!(strip_ansi(&rendered), "工作e\u{301}");
assert_eq!(visible_len(&rendered), 5);
}
}