1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::animation::AnimationState;
5use imp_core::config::AnimationLevel;
6
7use ratatui::buffer::Buffer;
8use ratatui::layout::Rect;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::Widget;
11
12use crate::theme::Theme;
13
14#[derive(Debug, Clone, Default)]
16pub struct StatusInfo {
17 pub cwd: String,
18 pub session_name: String,
19 pub model: String,
20 pub thinking: String,
21 pub input_tokens: u32,
22 pub output_tokens: u32,
23 pub current_context_tokens: u32,
24 pub cost: f64,
25 pub context_percent: f64,
26 pub context_window: u32,
27 pub show_cost: bool,
28 pub show_context_usage: bool,
29 pub peek: bool,
30 pub extension_items: HashMap<String, String>,
31 pub is_streaming: bool,
32 pub active_tools: u32,
33 pub turn_elapsed: Option<Duration>,
34 pub tick: u64,
35 pub animation_level: AnimationLevel,
36 pub activity_state: AnimationState,
37}
38
39pub struct StatusBar<'a> {
41 info: &'a StatusInfo,
42 theme: &'a Theme,
43}
44
45impl<'a> StatusBar<'a> {
46 pub fn new(info: &'a StatusInfo, theme: &'a Theme) -> Self {
47 Self { info, theme }
48 }
49}
50
51impl Widget for StatusBar<'_> {
52 fn render(self, area: Rect, buf: &mut Buffer) {
53 if area.height == 0 {
54 return;
55 }
56
57 let cwd_short = shorten_path(&self.info.cwd, 30);
59 let mut left_parts = vec![Span::styled(cwd_short, self.theme.accent_style())];
60
61 if !self.info.session_name.is_empty() {
62 left_parts.push(Span::styled(" │ ", self.theme.muted_style()));
63 left_parts.push(Span::styled(
64 self.info.session_name.clone(),
65 self.theme.muted_style(),
66 ));
67 }
68
69 for (key, val) in &self.info.extension_items {
71 left_parts.push(Span::styled(" │ ", self.theme.muted_style()));
72 left_parts.push(Span::styled(
73 format!("{key}: {val}"),
74 self.theme.muted_style(),
75 ));
76 }
77
78 let tokens_str = format!(
80 "↑{} ↓{}",
81 format_tokens(self.info.input_tokens),
82 format_tokens(self.info.output_tokens)
83 );
84 let cost_str = format!("${:.2}", self.info.cost);
85 let context_str = format!("{:.0}%", self.info.context_percent * 100.0);
86 let context_style = if self.info.context_percent > 0.75 {
88 self.theme.error_style()
89 } else if self.info.context_percent > 0.50 {
90 self.theme.warning_style()
91 } else {
92 self.theme.muted_style()
93 };
94
95 let mut right_parts = Vec::new();
96 if self.info.peek {
97 right_parts.push(Span::styled("👁 PEEK", self.theme.accent_style()));
98 right_parts.push(Span::styled(" │ ", self.theme.muted_style()));
99 }
100 right_parts.extend([
101 Span::styled(tokens_str, self.theme.muted_style()),
102 Span::styled(" │ ", self.theme.muted_style()),
103 Span::styled(cost_str, self.theme.muted_style()),
104 Span::styled(" │ ", self.theme.muted_style()),
105 Span::styled(context_str, context_style),
106 Span::styled(" │ ", self.theme.muted_style()),
107 Span::styled(self.info.model.clone(), self.theme.accent_style()),
108 ]);
109
110 let right_width: usize = right_parts.iter().map(|s| s.content.len()).sum();
112 let available = area.width as usize;
113
114 let line = if available > right_width + 4 {
115 let left_width: usize = left_parts.iter().map(|s| s.content.len()).sum();
117 let gap = available.saturating_sub(left_width + right_width);
118 let mut spans = left_parts;
119 spans.push(Span::raw(" ".repeat(gap)));
120 spans.extend(right_parts);
121 Line::from(spans)
122 } else {
123 Line::from(right_parts)
125 };
126
127 buf.set_line(area.x, area.y, &line, area.width);
128 }
129}
130
131fn format_tokens(tokens: u32) -> String {
132 if tokens >= 1_000_000 {
133 format!("{:.1}M", tokens as f64 / 1_000_000.0)
134 } else if tokens >= 1_000 {
135 format!("{:.1}k", tokens as f64 / 1_000.0)
136 } else {
137 format!("{tokens}")
138 }
139}
140
141fn shorten_path(path: &str, max_len: usize) -> String {
142 if path.len() <= max_len {
143 return path.to_string();
144 }
145 let parts: Vec<&str> = path.split('/').collect();
147 let mut result = String::new();
148 for part in parts.iter().rev() {
149 let candidate = if result.is_empty() {
150 part.to_string()
151 } else {
152 format!("{part}/{result}")
153 };
154 if candidate.len() > max_len {
155 break;
156 }
157 result = candidate;
158 }
159 if result.len() < path.len() {
160 format!("…/{result}")
161 } else {
162 result
163 }
164}