aico/commands/
log.rs

1// src/commands/log.rs
2
3use crate::console::strip_ansi_codes;
4use crate::exceptions::AicoError;
5use crate::historystore::reconstruct::reconstruct_history;
6use crate::models::Role;
7use crate::session::Session;
8use comfy_table::presets::NOTHING;
9use comfy_table::*;
10
11/// Helper to create and style a log row
12fn create_log_row(id: &str, role: &str, snippet: &str, color: Color, is_excluded: bool) -> Row {
13    let cells = vec![
14        Cell::new(id).set_alignment(CellAlignment::Right),
15        Cell::new(role).fg(color),
16        Cell::new(snippet),
17    ];
18
19    if is_excluded {
20        Row::from(
21            cells
22                .into_iter()
23                .map(|cell| cell.add_attribute(Attribute::Dim)),
24        )
25    } else {
26        Row::from(cells)
27    }
28}
29
30pub fn run() -> Result<(), AicoError> {
31    let session = Session::load_active()?;
32    let width = crate::console::get_terminal_width();
33
34    // Log needs to see excluded messages to show them as dimmed [-] entries
35    let history_vec = reconstruct_history(&session.store, &session.view, true)?;
36
37    let mut paired_history = Vec::new();
38    let mut dangling_history = Vec::new();
39
40    // Separate paired vs dangling for Python parity
41    let mut iter = history_vec.iter().peekable();
42
43    while let Some(current) = iter.next() {
44        // Check if the NEXT item completes a pair with the CURRENT item
45        let is_pair = current.record.role == Role::User
46            && iter.peek().is_some_and(|next| {
47                next.record.role == Role::Assistant && next.pair_index == current.pair_index
48            });
49
50        if is_pair {
51            paired_history.push(current);
52            // Safely consume the assistant message we just peeked at
53            paired_history.push(iter.next().unwrap());
54        } else {
55            dangling_history.push(current);
56        }
57    }
58
59    if paired_history.is_empty() && dangling_history.is_empty() {
60        println!("No message pairs found in active history.");
61        return Ok(());
62    }
63
64    let mut table = Table::new();
65    table
66        .load_preset(NOTHING)
67        .set_width(width as u16)
68        .set_content_arrangement(ContentArrangement::Dynamic);
69
70    // Match Python header: bold ID, Role, Message Snippet
71    table.set_header(vec![
72        Cell::new("ID")
73            .add_attribute(Attribute::Bold)
74            .set_alignment(CellAlignment::Right),
75        Cell::new("Role").add_attribute(Attribute::Bold),
76        Cell::new("Message Snippet").add_attribute(Attribute::Bold),
77    ]);
78
79    // Set constraints to let the snippet column take the majority of the room
80    table
81        .column_mut(0)
82        .unwrap()
83        .set_constraint(ColumnConstraint::ContentWidth);
84    table
85        .column_mut(1)
86        .unwrap()
87        .set_constraint(ColumnConstraint::ContentWidth);
88    let snippet_col = table.column_mut(2).unwrap();
89    snippet_col.set_constraint(ColumnConstraint::LowerBoundary(Width::Fixed(20)));
90
91    for item in paired_history {
92        let pair_idx = item.pair_index;
93        let is_excluded = item.is_excluded;
94
95        let id_display = if is_excluded {
96            format!("{}[-]", pair_idx)
97        } else {
98            pair_idx.to_string()
99        };
100
101        let (role_name, role_color) = match item.record.role {
102            Role::User => ("user", Color::Blue),
103            Role::Assistant => ("assistant", Color::Green),
104            Role::System => ("system", Color::Grey),
105        };
106
107        let snippet = item.record.content.lines().next().unwrap_or("").trim();
108
109        table.add_row(create_log_row(
110            if item.record.role == Role::User {
111                &id_display
112            } else {
113                ""
114            },
115            role_name,
116            snippet,
117            role_color,
118            is_excluded,
119        ));
120    }
121
122    let table_output = table.to_string();
123
124    // Calculate table width excluding ANSI codes to center title correctly
125    let plain_output = strip_ansi_codes(&table_output);
126    let table_full_width = plain_output.lines().next().unwrap_or("").len();
127
128    let title = "Active Context Log";
129    if table_full_width > title.len() {
130        let padding = (table_full_width - title.len()) / 2;
131        println!("{}{}{}", " ".repeat(padding), title, " ".repeat(padding));
132    } else {
133        println!("{}", title);
134    }
135
136    println!("{}", table_output);
137
138    if !dangling_history.is_empty() {
139        println!("\nDangling messages in active context:");
140        for item in dangling_history {
141            let role_name = match item.record.role {
142                Role::User => "user",
143                Role::Assistant => "assistant",
144                Role::System => "system",
145            };
146            let snippet = item.record.content.lines().next().unwrap_or("").trim();
147            println!("{}: {}", role_name, snippet);
148        }
149    }
150
151    Ok(())
152}