Skip to main content

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 bytes = s.as_bytes();
126
127    let mut groups: Vec<&str> = bytes
128        .rchunks(3)
129        .map(|chunk| std::str::from_utf8(chunk).unwrap()) // Safe because numbers are ASCII
130        .collect();
131
132    groups.reverse();
133
134    groups.join(",")
135}
136
137pub fn display_cost_summary(
138    usage: &TokenUsage,
139    current_cost: Option<f64>,
140    store: &HistoryStore,
141    view: &SessionView,
142) {
143    let mut prompt_info = format_tokens(usage.prompt_tokens);
144    if let Some(cached) = usage.cached_tokens
145        && cached > 0
146    {
147        prompt_info.push_str(&format!(" ({} cached)", format_tokens(cached)));
148    }
149
150    let mut completion_info = format_tokens(usage.completion_tokens);
151    if let Some(reasoning) = usage.reasoning_tokens
152        && reasoning > 0
153    {
154        completion_info.push_str(&format!(" ({} reasoning)", format_tokens(reasoning)));
155    }
156
157    let mut info = format!(
158        "Tokens: {} sent, {} received.",
159        prompt_info, completion_info
160    );
161
162    if let Some(cost) = current_cost {
163        let mut history_cost = 0.0;
164        let start_idx = view.history_start_pair * 2;
165
166        if start_idx < view.message_indices.len()
167            && let Ok(records) = store.read_many(&view.message_indices[start_idx..])
168        {
169            for record in records {
170                if record.role == Role::Assistant
171                    && let Some(c) = record.cost
172                {
173                    history_cost += c;
174                }
175            }
176        }
177
178        let total_chat = history_cost + cost;
179        info.push_str(&format!(
180            " Cost: ${:.2}, current chat: ${:.2}",
181            cost, total_chat
182        ));
183    }
184
185    if is_stdout_terminal() {
186        eprintln!("{}", "---".dim());
187        eprintln!("{}", info.dim());
188    } else {
189        eprintln!("{}", info);
190    }
191}