aico/commands/
status.rs

1use crate::exceptions::AicoError;
2use crate::llm::tokens::count_heuristic;
3use crate::model_registry::get_model_info;
4use crate::models::TokenInfo;
5use crate::session::Session;
6use comfy_table::ColumnConstraint;
7use comfy_table::Row;
8use comfy_table::Width;
9use comfy_table::{Attribute, Cell, CellAlignment, Table};
10use crossterm::style::Stylize;
11use std::fmt::Write as _;
12use std::io::Write;
13
14use crate::historystore::reconstruct::reconstruct_history;
15
16pub async fn run(json_output: bool) -> Result<(), AicoError> {
17    let session = Session::load_active()?;
18    let model_id = &session.view.model;
19    let model_info = get_model_info(model_id).await;
20    let width = crate::console::get_terminal_width();
21
22    // 1 & 2. System and Alignment Prompts
23    let components = vec![
24        TokenInfo {
25            description: "system prompt".into(),
26            tokens: crate::llm::tokens::SYSTEM_TOKEN_COUNT,
27            cost: None,
28        },
29        TokenInfo {
30            description: "alignment prompts (worst-case)".into(),
31            tokens: crate::llm::tokens::MAX_ALIGNMENT_TOKENS,
32            cost: None,
33        },
34    ];
35
36    // 3. Chat History
37    let history_vec = reconstruct_history(&session.store, &session.view, true)?;
38    let mut history_counter = crate::llm::tokens::HeuristicCounter::new();
39
40    for item in &history_vec {
41        if item.is_excluded {
42            continue;
43        }
44
45        let rec = &item.record;
46        if rec.role == crate::models::Role::User && !rec.passthrough {
47            if let Some(ref piped) = rec.piped_content {
48                let _ = write!(
49                    history_counter,
50                    "<stdin_content>\n{}\n</stdin_content>\n<prompt>\n{}\n</prompt>",
51                    piped.trim(),
52                    rec.content.trim()
53                );
54            } else {
55                history_counter.add_str(&rec.content);
56            }
57        } else {
58            history_counter.add_str(&rec.content);
59        }
60    }
61
62    let history_comp = TokenInfo {
63        description: "chat history".into(),
64        tokens: history_counter.count(),
65        cost: None,
66    };
67
68    // 4. Context Files
69    let mut sorted_keys: Vec<_> = session.context_content.keys().collect();
70    sorted_keys.sort();
71
72    let mut file_components: Vec<TokenInfo> = sorted_keys
73        .into_iter()
74        .map(|rel_path| {
75            let content = &session.context_content[rel_path];
76            let mut buffer = String::new();
77            crate::llm::executor::append_file_context_xml(&mut buffer, rel_path, content);
78            TokenInfo {
79                description: rel_path.clone(),
80                tokens: count_heuristic(&buffer),
81                cost: None,
82            }
83        })
84        .collect();
85
86    // Calculate Costs and Totals
87    let has_context_files = !file_components.is_empty();
88    let mut all_included = components;
89    all_included.push(history_comp);
90    all_included.append(&mut file_components);
91
92    let mut total_tokens = 0;
93    let mut total_cost = 0.0;
94    let mut has_cost_info = false;
95
96    for comp in &mut all_included {
97        total_tokens += comp.tokens;
98        if let Some(ref info) = model_info {
99            let usage = crate::models::TokenUsage {
100                prompt_tokens: comp.tokens,
101                completion_tokens: 0,
102                total_tokens: comp.tokens,
103                cached_tokens: None,
104                reasoning_tokens: None,
105                cost: None,
106            };
107            if let Some(cost) = crate::llm::tokens::calculate_cost_prefetched(info, &usage) {
108                comp.cost = Some(cost);
109                total_cost += cost;
110                has_cost_info = true;
111            }
112        }
113    }
114
115    if json_output {
116        let mut files = session.view.context_files.clone();
117        files.sort();
118
119        let session_name = session
120            .view_path
121            .file_stem()
122            .and_then(|s| s.to_str())
123            .unwrap_or("main")
124            .to_string();
125
126        let resp = crate::models::StatusResponse {
127            session_name,
128            model: model_id.clone(),
129            context_files: files,
130            total_tokens: if total_tokens > 0 {
131                Some(total_tokens)
132            } else {
133                None
134            },
135            total_cost: if has_cost_info {
136                Some(total_cost)
137            } else {
138                None
139            },
140        };
141        {
142            let mut stdout = std::io::stdout();
143            if let Err(e) = serde_json::to_writer(&mut stdout, &resp)
144                && !e.is_io()
145            {
146                return Err(AicoError::Session(e.to_string()));
147            }
148            let _ = writeln!(stdout);
149        }
150        return Ok(());
151    }
152
153    // Header Panel
154    let session_name = session
155        .view_path
156        .file_stem()
157        .and_then(|s| s.to_str())
158        .unwrap_or("main");
159
160    session.warn_missing_files();
161
162    crate::console::draw_panel(
163        &format!("Session '{}'", session_name),
164        std::slice::from_ref(model_id),
165        width,
166    );
167    println!();
168
169    // Table configured for parity with Python's rich
170    let mut table = Table::new();
171    table
172        .load_preset(comfy_table::presets::NOTHING)
173        .set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth)
174        .set_style(comfy_table::TableComponent::HeaderLines, '─')
175        .set_style(comfy_table::TableComponent::MiddleHeaderIntersections, ' ')
176        .set_width(width as u16)
177        .set_truncation_indicator("");
178
179    table.set_header(vec![
180        Cell::new("Tokens\n(approx.)").add_attribute(Attribute::Bold),
181        Cell::new("Cost").add_attribute(Attribute::Bold),
182        Cell::new("Component").add_attribute(Attribute::Bold),
183    ]);
184
185    // Apply constraints to ensure predictable alignment with panels
186    table
187        .column_mut(0)
188        .unwrap()
189        .set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(10)));
190    table
191        .column_mut(1)
192        .unwrap()
193        .set_constraint(ColumnConstraint::UpperBoundary(Width::Fixed(10)));
194
195    // 1 & 2. System and Alignment
196    for comp in all_included.iter().take(2) {
197        let cost_str = comp.cost.map(|c| format!("${:.5}", c)).unwrap_or_default();
198        table.add_row(vec![
199            Cell::new(crate::console::format_thousands(comp.tokens)),
200            Cell::new(cost_str),
201            Cell::new(&comp.description),
202        ]);
203    }
204
205    // 3. History
206    let history_cost_str = all_included[2]
207        .cost
208        .map(|c| format!("${:.5}", c))
209        .unwrap_or_default();
210    table.add_row(vec![
211        Cell::new(crate::console::format_thousands(all_included[2].tokens)),
212        Cell::new(history_cost_str),
213        Cell::new(&all_included[2].description),
214    ]);
215
216    // History Summary Line
217    if let Ok(Some(summary)) = session.summarize_active_window(&history_vec) {
218        if summary.active_pairs > 0 {
219            let mut line1 = format!(
220                "└─ Active window: {} pair{} (IDs {}-{}), {} sent",
221                summary.active_pairs,
222                if summary.active_pairs == 1 { "" } else { "s" },
223                summary.active_start_id,
224                summary.active_end_id,
225                summary.pairs_sent
226            );
227
228            if summary.excluded_in_window > 0 {
229                line1.push_str(&format!(" ({} excluded)", summary.excluded_in_window));
230            }
231            line1.push('.');
232
233            let line2 = "(Use `aico log`, `undo`, and `set-history` to manage)";
234
235            let (fmt1, fmt2) = if crate::console::is_stdout_terminal() {
236                (
237                    format!("   {}", line1).dim().to_string(),
238                    format!("      {}", line2).dim().italic().to_string(),
239                )
240            } else {
241                (format!("   {}", line1), format!("      {}", line2))
242            };
243
244            table.add_row(vec![Cell::new(""), Cell::new(""), Cell::new(fmt1)]);
245            table.add_row(vec![Cell::new(""), Cell::new(""), Cell::new(fmt2)]);
246        }
247
248        if summary.has_dangling {
249            table.add_row(vec![
250                Cell::new(""),
251                Cell::new(""),
252                Cell::new(
253                    "Active context contains partial/dangling messages."
254                        .yellow()
255                        .to_string(),
256                ),
257            ]);
258        }
259    }
260
261    // 4. Context Files
262    if has_context_files {
263        let mut separator = Row::from(vec![
264            Cell::new("─".repeat(width)),
265            Cell::new("─".repeat(width)),
266            Cell::new("─".repeat(width)),
267        ]);
268        separator.max_height(1);
269        table.add_row(separator);
270
271        for comp in all_included.iter().skip(3) {
272            let cost_str = comp.cost.map(|c| format!("${:.5}", c)).unwrap_or_default();
273            table.add_row(vec![
274                Cell::new(crate::console::format_thousands(comp.tokens)),
275                Cell::new(cost_str),
276                Cell::new(&comp.description),
277            ]);
278        }
279    }
280
281    // Final Total Row
282    let mut separator = Row::from(vec![
283        Cell::new("─".repeat(width)),
284        Cell::new("─".repeat(width)),
285        Cell::new("─".repeat(width)),
286    ]);
287    separator.max_height(1);
288    table.add_row(separator);
289
290    let total_cost_str = if has_cost_info {
291        format!("${:.5}", total_cost)
292    } else {
293        "".to_string()
294    };
295    table.add_row(vec![
296        Cell::new(format!(
297            "~{}",
298            crate::console::format_thousands(total_tokens)
299        ))
300        .add_attribute(Attribute::Bold),
301        Cell::new(total_cost_str).add_attribute(Attribute::Bold),
302        Cell::new("Total").add_attribute(Attribute::Bold),
303    ]);
304
305    table
306        .column_mut(0)
307        .unwrap()
308        .set_padding((0, 0))
309        .set_cell_alignment(CellAlignment::Right);
310    table
311        .column_mut(1)
312        .unwrap()
313        .set_padding((0, 0))
314        .set_cell_alignment(CellAlignment::Right);
315    table
316        .column_mut(2)
317        .unwrap()
318        .set_padding((0, 0))
319        .set_cell_alignment(CellAlignment::Left);
320
321    println!("{}", table);
322
323    // Context Window Panel
324    if let Some(info) = model_info
325        && let Some(max) = info.max_input_tokens
326    {
327        println!();
328        let pct = (total_tokens as f64 / max as f64).min(1.0);
329        let bar_max_width = width.saturating_sub(4);
330        let filled = (pct * bar_max_width as f64) as usize;
331
332        let bar_filled = "━".repeat(filled);
333        let bar = format!(
334            "{}{}",
335            if crate::console::is_stdout_terminal() {
336                bar_filled.cyan().bold().to_string()
337            } else {
338                bar_filled
339            },
340            " ".repeat(bar_max_width.saturating_sub(filled))
341        );
342
343        let summary = format!(
344            "({} of {} used - {:.0}% remaining)",
345            crate::console::format_thousands(total_tokens),
346            crate::console::format_thousands(max),
347            (1.0 - pct) * 100.0
348        );
349
350        crate::console::draw_panel("Context Window", &[summary, bar], width);
351    }
352
353    Ok(())
354}