1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Modifier, Style},
5 text::{Line, Span},
6 widgets::{Paragraph, Widget},
7};
8
9use crate::theme;
10use crate::widgets::spinner::FRAMES;
11
12const SEP_RIGHT: &str = "\u{E0B0}"; const SEP_LEFT: &str = "\u{E0B2}"; pub struct Footer<'a> {
17 pub cwd: &'a str,
18 pub model: &'a str,
19 pub mode: &'a str,
20 pub cost: f64,
21 pub git_branch: Option<&'a str>,
22 pub is_streaming: bool,
23 pub is_pending: bool,
24 pub is_paused: bool,
25 pub spinner_frame: usize,
26 pub elapsed_secs: u64,
27}
28
29fn shrink_path(cwd: &str, max: usize) -> String {
30 let with_tilde = if let Some(home) = std::env::var_os("HOME") {
31 if let Some(home) = home.to_str() {
32 if cwd.starts_with(home) {
33 format!("~{}", &cwd[home.len()..])
34 } else {
35 cwd.to_string()
36 }
37 } else {
38 cwd.to_string()
39 }
40 } else {
41 cwd.to_string()
42 };
43
44 if with_tilde.len() <= max {
45 return with_tilde;
46 }
47 let take = max.saturating_sub(1);
48 let start = with_tilde.len().saturating_sub(take);
49 format!("…{}", &with_tilde[start..])
50}
51
52fn pretty_model(model: &str) -> String {
53 let s = model.trim_start_matches("claude-");
54 s.split('-').collect::<Vec<_>>().join("·")
55}
56
57fn fmt_elapsed_short(secs: u64) -> String {
58 if secs >= 60 {
59 format!("{}m {}s", secs / 60, secs % 60)
60 } else {
61 format!("{secs}s")
62 }
63}
64
65struct Seg {
67 text: String,
68 fg: Color,
69 bg: Color,
70 bold: bool,
71 italic: bool,
72}
73
74impl Seg {
75 fn new(text: impl Into<String>, fg: Color, bg: Color) -> Self {
76 Self { text: text.into(), fg, bg, bold: false, italic: false }
77 }
78 fn bold(mut self) -> Self {
79 self.bold = true;
80 self
81 }
82 fn italic(mut self) -> Self {
83 self.italic = true;
84 self
85 }
86 fn style(&self) -> Style {
87 let mut s = Style::default().fg(self.fg).bg(self.bg);
88 if self.bold {
89 s = s.add_modifier(Modifier::BOLD);
90 }
91 if self.italic {
92 s = s.add_modifier(Modifier::ITALIC);
93 }
94 s
95 }
96}
97
98fn build_left(segs: &[Seg], bar_bg: Color) -> Line<'static> {
101 let mut spans: Vec<Span<'static>> = Vec::new();
102 for (i, seg) in segs.iter().enumerate() {
103 spans.push(Span::styled(format!(" {} ", seg.text), seg.style()));
104 let next_bg = segs.get(i + 1).map(|s| s.bg).unwrap_or(bar_bg);
105 spans.push(Span::styled(
106 SEP_RIGHT,
107 Style::default().fg(seg.bg).bg(next_bg),
108 ));
109 }
110 Line::from(spans)
111}
112
113fn build_right(segs: &[Seg], bar_bg: Color) -> Line<'static> {
116 let mut spans: Vec<Span<'static>> = Vec::new();
117 for (i, seg) in segs.iter().enumerate() {
118 let prev_bg = if i == 0 { bar_bg } else { segs[i - 1].bg };
119 spans.push(Span::styled(
120 SEP_LEFT,
121 Style::default().fg(seg.bg).bg(prev_bg),
122 ));
123 spans.push(Span::styled(format!(" {} ", seg.text), seg.style()));
124 }
125 Line::from(spans)
126}
127
128impl<'a> Widget for Footer<'a> {
129 fn render(self, area: Rect, buf: &mut Buffer) {
130 let bar_bg = theme::BASE();
132 let block_bg = theme::OVERLAY(); let accent = theme::IRIS(); let base = theme::BASE();
135
136 for x in area.x..area.x + area.width {
137 buf[(x, area.y)].set_style(Style::default().bg(bar_bg));
138 }
139
140 let mut left_segs: Vec<Seg> = Vec::new();
142
143 if self.mode != "Normal" {
146 let (icon, color) = match self.mode {
147 "Auto-accept" => ("\u{26A1}", theme::GOLD()), "Plan" => ("\u{25C6}", theme::IRIS()), "Bypass" => ("\u{26A0}", theme::LOVE()), _ => ("\u{25CF}", theme::FOAM()), };
152 left_segs.push(Seg::new(format!("{icon} {}", self.mode), base, color).bold());
153 }
154
155 let max_path = (area.width as usize).saturating_sub(46).max(10);
157 left_segs.push(
158 Seg::new(
159 format!("\u{F07C} {}", shrink_path(self.cwd, max_path)),
160 base,
161 accent,
162 )
163 .bold(),
164 );
165
166 if let Some(branch) = self.git_branch {
167 left_segs.push(Seg::new(
168 format!("\u{E0A0} {branch}"),
169 theme::FOAM(),
170 block_bg,
171 ));
172 }
173
174 let mut right_segs: Vec<Seg> = Vec::new();
176
177 if self.is_paused {
178 right_segs.push(
179 Seg::new("\u{23F8} paused", theme::GOLD(), block_bg)
180 .bold()
181 .italic(),
182 );
183 } else if self.is_pending {
184 let ch = FRAMES[self.spinner_frame % FRAMES.len()];
185 right_segs.push(
186 Seg::new(format!("{ch} connecting…"), theme::SUBTLE(), block_bg).italic(),
187 );
188 } else if self.is_streaming {
189 let ch = FRAMES[self.spinner_frame % FRAMES.len()];
190 let elapsed = fmt_elapsed_short(self.elapsed_secs);
191 right_segs.push(
192 Seg::new(
193 format!("{ch} generating {elapsed}"),
194 theme::FOAM(),
195 block_bg,
196 )
197 .italic(),
198 );
199 }
200
201 right_segs.push(
202 Seg::new(
203 format!("\u{25C7} {}", pretty_model(self.model)),
204 theme::TEXT(),
205 block_bg,
206 )
207 .bold(),
208 );
209
210 right_segs.push(Seg::new(format!("${:.4}", self.cost), base, accent).bold());
212
213 let left = build_left(&left_segs, bar_bg);
214 let right = build_right(&right_segs, bar_bg);
215 let left_width = left.width() as u16;
216 let right_width = right.width() as u16;
217
218 Paragraph::new(left)
219 .style(Style::default().bg(bar_bg))
220 .render(Rect { width: left_width.min(area.width), ..area }, buf);
221
222 if area.width > right_width {
223 let right_area = Rect {
224 x: area.x + area.width - right_width,
225 y: area.y,
226 width: right_width,
227 height: 1,
228 };
229 Paragraph::new(right)
230 .style(Style::default().bg(bar_bg))
231 .render(right_area, buf);
232 }
233 }
234}