aico/
console.rs

1use crate::historystore::store::HistoryStore;
2use crate::models::Role;
3use crate::models::{SessionView, TokenUsage};
4use crossterm::style::Stylize;
5use std::io::IsTerminal;
6use unicode_width::UnicodeWidthStr;
7
8pub const ANSI_REGEX_PATTERN: &str = r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b].*?(\x1b\\|[\x07])";
9
10pub fn strip_ansi_codes(s: &str) -> String {
11    static RE: std::sync::LazyLock<regex::Regex> =
12        std::sync::LazyLock::new(|| regex::Regex::new(ANSI_REGEX_PATTERN).unwrap());
13    RE.replace_all(s, "").to_string()
14}
15
16pub fn get_terminal_width() -> usize {
17    static TERMINAL_WIDTH: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
18        // 1. Check AICO_COLUMNS
19        if let Ok(w) = std::env::var("AICO_COLUMNS").map(|s| s.parse().unwrap_or(0))
20            && w > 0
21        {
22            return w;
23        }
24
25        // 2. Check COLUMNS
26        if let Ok(w) = std::env::var("COLUMNS").map(|s| s.parse().unwrap_or(0))
27            && w > 0
28        {
29            return w;
30        }
31
32        // 3. System TTY (Only called if env vars are missing)
33        if is_stdout_terminal()
34            && let Ok((w, _)) = crossterm::terminal::size()
35        {
36            return w as usize;
37        }
38
39        // 4. Default Fallback
40        80
41    });
42
43    *TERMINAL_WIDTH
44}
45
46pub fn draw_panel(title: &str, lines: &[String], width: usize) {
47    let inner_width = width.saturating_sub(2);
48    let title_fmt = if !title.is_empty() {
49        format!(" {} ", title)
50    } else {
51        "".to_string()
52    };
53
54    let title_width = UnicodeWidthStr::width(title_fmt.as_str());
55    let total_dashes = inner_width.saturating_sub(title_width);
56    let left_dashes = total_dashes / 2;
57    let right_dashes = total_dashes - left_dashes;
58
59    println!(
60        "╭{}{}{}╮",
61        "─".repeat(left_dashes),
62        title_fmt,
63        "─".repeat(right_dashes)
64    );
65
66    for line in lines {
67        let stripped = strip_ansi_codes(line);
68        let visible_len = UnicodeWidthStr::width(stripped.as_str());
69        let total_padding = inner_width.saturating_sub(visible_len);
70        let left_padding = total_padding / 2;
71        let right_padding = total_padding - left_padding;
72
73        println!(
74            "│{}{}{}│",
75            " ".repeat(left_padding),
76            line,
77            " ".repeat(right_padding)
78        );
79    }
80
81    println!("╰{}╯", "─".repeat(inner_width));
82}
83
84pub fn is_stdout_terminal() -> bool {
85    if std::env::var("AICO_FORCE_TTY").is_ok() {
86        return true;
87    }
88    std::io::stdout().is_terminal()
89}
90
91pub fn is_stdin_terminal() -> bool {
92    std::io::stdin().is_terminal()
93}
94
95use crate::models::Mode;
96
97pub fn format_piped_output<'a>(
98    unified_diff: &'a Option<String>,
99    raw_content: &'a str,
100    mode: &Mode,
101) -> &'a str {
102    if matches!(mode, Mode::Diff) {
103        return unified_diff.as_deref().unwrap_or("");
104    }
105
106    if let Some(diff) = unified_diff
107        && !diff.is_empty()
108    {
109        return diff.as_str();
110    }
111
112    raw_content
113}
114
115pub fn format_tokens(n: u32) -> String {
116    if n >= 1000 {
117        format!("{:.1}k", n as f64 / 1000.0)
118    } else {
119        n.to_string()
120    }
121}
122
123pub fn format_thousands(n: u32) -> String {
124    let s = n.to_string();
125    let len = s.len();
126    let num_commas = (len.saturating_sub(1)) / 3;
127    let mut result = String::with_capacity(len + num_commas);
128
129    let offset = len % 3;
130    let mut chars = s.chars();
131
132    if offset > 0 {
133        for _ in 0..offset {
134            result.push(chars.next().unwrap());
135        }
136    }
137
138    for (i, c) in chars.enumerate() {
139        if i % 3 == 0 && (offset > 0 || i > 0) {
140            result.push(',');
141        }
142        result.push(c);
143    }
144    result
145}
146
147pub fn display_cost_summary(
148    usage: &TokenUsage,
149    current_cost: Option<f64>,
150    store: &HistoryStore,
151    view: &SessionView,
152) {
153    let mut prompt_info = format_tokens(usage.prompt_tokens);
154    if let Some(cached) = usage.cached_tokens
155        && cached > 0
156    {
157        prompt_info.push_str(&format!(" ({} cached)", format_tokens(cached)));
158    }
159
160    let mut completion_info = format_tokens(usage.completion_tokens);
161    if let Some(reasoning) = usage.reasoning_tokens
162        && reasoning > 0
163    {
164        completion_info.push_str(&format!(" ({} reasoning)", format_tokens(reasoning)));
165    }
166
167    let mut info = format!(
168        "Tokens: {} sent, {} received.",
169        prompt_info, completion_info
170    );
171
172    if let Some(cost) = current_cost {
173        let mut history_cost = 0.0;
174
175        if let Ok(records) = store.read_many(&view.message_indices) {
176            let start_idx = view.history_start_pair * 2;
177            for (i, record) in records.iter().enumerate() {
178                if i >= start_idx
179                    && record.role == Role::Assistant
180                    && let Some(c) = record.cost
181                {
182                    history_cost += c;
183                }
184            }
185        }
186
187        let total_chat = history_cost + cost;
188        info.push_str(&format!(
189            " Cost: ${:.2}, current chat: ${:.2}",
190            cost, total_chat
191        ));
192    }
193
194    if is_stdout_terminal() {
195        eprintln!("{}", "---".dim());
196        eprintln!("{}", info.dim());
197    } else {
198        eprintln!("{}", info);
199    }
200}