mi6_cli/display/
event.rs

1//! Event formatting and row rendering.
2
3use chrono::{DateTime, Local};
4
5use mi6_core::{Event, EventType};
6
7use super::string::{truncate_from_start, truncate_str, truncate_with_prefix};
8use super::table::{COL_APP, COL_EVENT, COL_PID, COL_SESSION, COL_SOURCE, COL_TIME};
9
10/// Get 3-letter abbreviation for a framework.
11pub fn framework_abbrev(framework: Option<&str>) -> &'static str {
12    match framework {
13        Some("claude") => "CLA",
14        Some("gemini") => "GEM",
15        Some("cursor") => "CUR",
16        Some("opencode") => "OPE",
17        Some("codex") => "COD",
18        Some(_) => "???",
19        None => "---",
20    }
21}
22
23/// Get the display event type name for an event.
24/// For API requests, check metadata for Codex activity event types.
25pub fn get_display_event_type(event: &Event) -> String {
26    if event.event_type == EventType::ApiRequest {
27        // For 0-token entries, check metadata for event_type (Codex activity events)
28        if event.tokens_input == Some(0)
29            && event.tokens_output == Some(0)
30            && let Some(ref metadata) = event.metadata
31            && let Ok(json) = serde_json::from_str::<serde_json::Value>(metadata)
32            && let Some(event_type) = json.get("event_type").and_then(|v| v.as_str())
33        {
34            return match event_type {
35                "conversation_starts" => "ConvStart".to_string(),
36                "user_prompt" => "UserPrompt".to_string(),
37                "tool_decision" => "ToolDecision".to_string(),
38                "tool_result" => "ToolResult".to_string(),
39                "api_request" => "ApiMeta".to_string(),
40                _ => "ApiRequest".to_string(),
41            };
42        }
43        "ApiRequest".to_string()
44    } else {
45        event.event_type.to_string()
46    }
47}
48
49/// Print a single event as a table row.
50///
51/// When `raw_mode` is true, uses `\r\n` line endings (for raw terminal mode).
52/// When `raw_mode` is false, uses normal `\n` line endings.
53pub fn print_event_row(event: &Event, show_app: bool, details_width: usize, raw_mode: bool) {
54    let local_time: DateTime<Local> = event.timestamp.into();
55    let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string();
56    let time = truncate_str(&time_str, COL_TIME);
57    let event_type_str = get_display_event_type(event);
58    let event_type = truncate_str(&event_type_str, COL_EVENT);
59    let session_short = truncate_str(&event.session_id, COL_SESSION);
60    let pid_str = event.pid.map_or_else(|| "-".to_string(), |p| p.to_string());
61    let source = source_abbrev(event.source.as_deref());
62    let details_str = format_details(event);
63    let details = truncate_str(&details_str, details_width);
64
65    if show_app {
66        let app = framework_abbrev(event.framework.as_deref());
67        if raw_mode {
68            print!(
69                "{:<COL_TIME$}  {:<COL_APP$}  {:<COL_EVENT$}  {:<COL_SESSION$}  {:<COL_PID$}  {:<COL_SOURCE$}  {}\r\n",
70                time, app, event_type, session_short, pid_str, source, details
71            );
72        } else {
73            println!(
74                "{:<COL_TIME$}  {:<COL_APP$}  {:<COL_EVENT$}  {:<COL_SESSION$}  {:<COL_PID$}  {:<COL_SOURCE$}  {}",
75                time, app, event_type, session_short, pid_str, source, details
76            );
77        }
78    } else if raw_mode {
79        print!(
80            "{:<COL_TIME$}  {:<COL_EVENT$}  {:<COL_SESSION$}  {:<COL_PID$}  {:<COL_SOURCE$}  {}\r\n",
81            time, event_type, session_short, pid_str, source, details
82        );
83    } else {
84        println!(
85            "{:<COL_TIME$}  {:<COL_EVENT$}  {:<COL_SESSION$}  {:<COL_PID$}  {:<COL_SOURCE$}  {}",
86            time, event_type, session_short, pid_str, source, details
87        );
88    }
89}
90
91/// Get source abbreviation for an event.
92fn source_abbrev(source: Option<&str>) -> &'static str {
93    match source {
94        Some("hook") => "hook",
95        Some("otel") => "otel",
96        Some("transcript") => "tscr",
97        Some(_) => "?",
98        None => "-",
99    }
100}
101
102/// Format details for an event.
103pub fn format_details(event: &Event) -> String {
104    let mut parts: Vec<String> = Vec::new();
105
106    // Show permission_mode if not default
107    if let Some(ref mode) = event.permission_mode
108        && mode != "default"
109    {
110        parts.push(format!("mode={}", mode));
111    }
112
113    // Add event-type-specific details
114    match &event.event_type {
115        EventType::PreToolUse | EventType::PostToolUse => {
116            format_tool_use_details(event, &mut parts);
117        }
118        EventType::Notification => format_notification_details(event, &mut parts),
119        EventType::SessionStart => format_session_start_details(event, &mut parts),
120        EventType::ApiRequest => format_api_request_details(event, &mut parts),
121        _ => format_default_details(event, &mut parts),
122    }
123
124    parts.join(" ")
125}
126
127/// Parse JSON payload from an event, returning None if absent or invalid.
128fn parse_payload(event: &Event) -> Option<serde_json::Value> {
129    event
130        .payload
131        .as_ref()
132        .and_then(|p| serde_json::from_str(p).ok())
133}
134
135/// Format details for PreToolUse and PostToolUse events.
136fn format_tool_use_details(event: &Event, parts: &mut Vec<String>) {
137    if let Some(ref tool) = event.tool_name {
138        parts.push(format!("tool={}", tool));
139    }
140
141    if let Some(ref subagent) = event.subagent_type {
142        parts.push(format!("subagent={}", subagent));
143    }
144
145    if let Some(ref agent_id) = event.spawned_agent_id {
146        let short_id = truncate_from_start(agent_id, 12);
147        parts.push(format!("spawned={}", short_id));
148    }
149}
150
151/// Format details for Notification events.
152fn format_notification_details(event: &Event, parts: &mut Vec<String>) {
153    let Some(payload) = parse_payload(event) else {
154        return;
155    };
156
157    let ntype = payload
158        .get("notification_type")
159        .and_then(|v| v.as_str())
160        .unwrap_or("?");
161    parts.push(format!("type={}", ntype));
162}
163
164/// Format details for SessionStart events.
165fn format_session_start_details(event: &Event, parts: &mut Vec<String>) {
166    if let Some(payload) = parse_payload(event) {
167        let source = payload
168            .get("source")
169            .and_then(|v| v.as_str())
170            .unwrap_or("?");
171        parts.push(format!("source={}", source));
172    }
173
174    if let Some(ref path) = event.transcript_path {
175        let short_path = truncate_with_prefix(path, 40);
176        parts.push(format!("transcript={}", short_path));
177    }
178}
179
180/// Format details for ApiRequest events.
181fn format_api_request_details(event: &Event, parts: &mut Vec<String>) {
182    // For 0-token activity events, show metadata details instead of token counts
183    if event.tokens_input == Some(0)
184        && event.tokens_output == Some(0)
185        && let Some(details) = format_activity_event_details(event)
186    {
187        parts.push(details);
188        return;
189    }
190
191    // Show model if available
192    if let Some(ref model) = event.model {
193        // Shorten common model names
194        let short_model = model
195            .replace("claude-", "c-")
196            .replace("sonnet", "son")
197            .replace("opus", "op")
198            .replace("haiku", "hai");
199        parts.push(format!("model={}", short_model));
200    }
201
202    // Show token counts
203    let tokens_in = event.tokens_input.unwrap_or(0);
204    let tokens_out = event.tokens_output.unwrap_or(0);
205    let total = tokens_in + tokens_out;
206    parts.push(format!("in={} out={} ({})", tokens_in, tokens_out, total));
207
208    // Show cache if present
209    if let Some(cache_read) = event.tokens_cache_read
210        && cache_read > 0
211    {
212        parts.push(format!("cache={}", cache_read));
213    }
214
215    // Show cost if available
216    if let Some(cost) = event.cost_usd {
217        parts.push(format!("${:.4}", cost));
218    }
219}
220
221/// Format details for Codex activity events (0-token metadata events).
222fn format_activity_event_details(event: &Event) -> Option<String> {
223    let metadata = event.metadata.as_ref()?;
224    let json: serde_json::Value = serde_json::from_str(metadata).ok()?;
225    let event_type = json.get("event_type")?.as_str()?;
226
227    let mut parts: Vec<String> = Vec::new();
228
229    match event_type {
230        "conversation_starts" => {
231            if let Some(v) = json.get("approval_policy").and_then(|v| v.as_str()) {
232                parts.push(format!("approval={}", v));
233            }
234            if let Some(v) = json.get("sandbox_policy").and_then(|v| v.as_str()) {
235                parts.push(format!("sandbox={}", v));
236            }
237            if let Some(v) = json.get("provider").and_then(|v| v.as_str()) {
238                parts.push(format!("provider={}", v));
239            }
240        }
241        "user_prompt" => {
242            if let Some(v) = json.get("prompt_length").and_then(|v| v.as_i64()) {
243                parts.push(format!("len={}", v));
244            }
245        }
246        "tool_decision" => {
247            if let Some(v) = json.get("tool_name").and_then(|v| v.as_str()) {
248                parts.push(format!("tool={}", v));
249            }
250            if let Some(v) = json.get("decision").and_then(|v| v.as_str()) {
251                parts.push(format!("decision={}", v));
252            }
253        }
254        "tool_result" => {
255            if let Some(v) = json.get("tool_name").and_then(|v| v.as_str()) {
256                parts.push(format!("tool={}", v));
257            }
258            if let Some(v) = json.get("success").and_then(|v| v.as_bool()) {
259                parts.push(if v {
260                    "ok".to_string()
261                } else {
262                    "FAIL".to_string()
263                });
264            }
265            if let Some(v) = json.get("duration_ms").and_then(|v| v.as_i64()) {
266                parts.push(format!("{}ms", v));
267            }
268        }
269        "api_request" => {
270            if let Some(v) = json.get("status_code").and_then(|v| v.as_i64()) {
271                parts.push(format!("status={}", v));
272            }
273            if let Some(v) = json.get("attempt").and_then(|v| v.as_i64())
274                && v > 1
275            {
276                parts.push(format!("attempt={}", v));
277            }
278            if let Some(v) = json.get("duration_ms").and_then(|v| v.as_i64()) {
279                parts.push(format!("{}ms", v));
280            }
281        }
282        _ => return None,
283    }
284
285    if parts.is_empty() {
286        None
287    } else {
288        Some(parts.join(" "))
289    }
290}
291
292/// Format details for other/unknown event types.
293fn format_default_details(event: &Event, parts: &mut Vec<String>) {
294    if let Some(ref cwd) = event.cwd {
295        let short_cwd = truncate_with_prefix(cwd, 30);
296        parts.push(format!("cwd={}", short_cwd));
297    }
298}