1use oxicode_vtui::design::layout::{
13 AgentViewLayout, CompactConfig, HintItem, LayoutConfig, LayoutInput, PendingHint,
14 ScrollbarConfig, ShortcutBarStyling, ShortcutsBar, StatusBar, effective_compact,
15};
16use oxicode_vtui::theme::{ThemeStyles, active_styles};
17use ratatui::{
18 Frame,
19 layout::Rect,
20 style::{Color, Modifier, Style},
21 text::{Line, Span},
22};
23
24use super::main_loop::RenderState;
25
26const COMPOSER_HEIGHT: u16 = 3;
28const SHORTCUTS_HEIGHT: u16 = 1;
30
31fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
37 match color {
38 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
39 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
40 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
41 None => Color::Reset,
42 }
43}
44
45fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
46 use anstyle::AnsiColor as A;
47 match color {
48 A::Black => Color::Black,
49 A::Red => Color::Red,
50 A::Green => Color::Green,
51 A::Yellow => Color::Yellow,
52 A::Blue => Color::Blue,
53 A::Magenta => Color::Magenta,
54 A::Cyan => Color::Cyan,
55 A::White => Color::Gray,
56 A::BrightBlack => Color::DarkGray,
57 A::BrightRed => Color::LightRed,
58 A::BrightGreen => Color::LightGreen,
59 A::BrightYellow => Color::LightYellow,
60 A::BrightBlue => Color::LightBlue,
61 A::BrightMagenta => Color::LightMagenta,
62 A::BrightCyan => Color::LightCyan,
63 A::BrightWhite => Color::White,
64 }
65}
66
67struct ThemeShortcutStyles<'a> {
74 styles: &'a ThemeStyles,
75}
76
77impl ShortcutBarStyling for ThemeShortcutStyles<'_> {
78 fn key_style(&self) -> Style {
79 Style::default()
80 .fg(color_from_anstyle(self.styles.primary.get_fg_color()))
81 .add_modifier(Modifier::BOLD)
82 }
83
84 fn label_style(&self) -> Style {
85 Style::default().fg(color_from_anstyle(Some(self.styles.foreground)))
86 }
87
88 fn separator_style(&self) -> Style {
89 Style::default().fg(color_from_anstyle(self.styles.secondary.get_fg_color()))
90 }
91
92 fn background_style(&self) -> Style {
93 Style::default().bg(color_from_anstyle(Some(self.styles.background)))
94 }
95
96 fn pending_key_style(&self) -> Style {
97 Style::default()
98 .fg(color_from_anstyle(self.styles.error.get_fg_color()))
99 .add_modifier(Modifier::BOLD)
100 }
101}
102
103fn shortcut_hints() -> Vec<HintItem> {
112 vec![
113 HintItem::new("Tab", "complete"),
114 HintItem::new("Enter", "send").pinned(),
115 HintItem::new("Esc", "cancel").pinned(),
116 HintItem::new("Ctrl+C", "interrupt"),
117 HintItem::paired("Up", "Down", "scroll"),
118 HintItem::paired("PgUp", "PgDn", "page"),
119 ]
120}
121
122pub(super) fn render_chrome(
130 frame: &mut Frame<'_>,
131 area: Rect,
132 state: &RenderState,
133) -> AgentViewLayout {
134 let styles = active_styles();
135 let compact = effective_compact(false, area.height);
136
137 let layout = AgentViewLayout::compute(
138 area,
139 &LayoutConfig::default(),
140 &ScrollbarConfig {
141 enabled: false,
142 ..Default::default()
143 },
144 LayoutInput {
145 prompt_height: COMPOSER_HEIGHT,
146 shortcuts_height: SHORTCUTS_HEIGHT,
147 compact,
148 ..LayoutInput::default()
149 },
150 );
151
152 let bg = color_from_anstyle(Some(styles.background));
154 let status = StatusBar::new(header_line(state, &styles))
155 .right(footer_line(state, &styles, layout.scrollback))
156 .style(Style::default().bg(bg));
157 frame.render_widget(status, layout.status_bar);
158
159 let hints = shortcut_hints();
161 let shortcut_styles = ThemeShortcutStyles { styles: &styles };
162 let mut bar = ShortcutsBar::new(&hints, &shortcut_styles);
163 if state.pending_quit {
164 bar = bar.pending(PendingHint {
165 key: "Ctrl+C",
166 label: "quit",
167 });
168 }
169 let compact_cfg = CompactConfig::default();
170 if compact {
171 bar = bar.compact(&compact_cfg);
172 }
173 frame.render_widget(bar, layout.shortcuts);
174
175 layout
176}
177
178fn header_line<'a>(state: &'a RenderState, styles: &ThemeStyles) -> Line<'a> {
180 let ctx = &state.header_context;
181 let fg = color_from_anstyle(Some(styles.foreground));
182 let bg = color_from_anstyle(Some(styles.background));
183 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
184 let info = color_from_anstyle(styles.info.get_fg_color());
185
186 Line::from(vec![
187 Span::styled(
188 format!(" {} ", ctx.app_name),
189 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
190 ),
191 Span::styled(format!(" {} ", ctx.provider), Style::default().fg(info)),
192 Span::raw(" "),
193 Span::styled(
194 format!("\u{2500} {} ", ctx.model),
195 Style::default().fg(secondary),
196 ),
197 Span::raw(" "),
198 Span::styled(
199 format!("\u{2387} {} ", ctx.git),
200 Style::default().fg(secondary),
201 ),
202 Span::raw(" "),
203 Span::styled(
204 format!("\u{2699} {}", ctx.tools),
205 Style::default().fg(secondary),
206 ),
207 ])
208}
209
210fn footer_line<'a>(state: &'a RenderState, styles: &ThemeStyles, area: Rect) -> Line<'a> {
212 let left = state.footer_left.clone().unwrap_or_default();
213 let right = state.footer_right.clone().unwrap_or_else(|| {
219 let total = state.transcript.len();
220 if state.scroll_offset == usize::MAX {
221 format!("line {total}/{total}")
222 } else {
223 let off = super::main_loop::effective_scroll_offset(
224 state.scroll_offset,
225 total,
226 area.height as usize,
227 );
228 format!("line {}/{}", off.min(total), total)
229 }
230 });
231
232 Line::from(vec![
233 Span::styled(
234 left,
235 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
236 ),
237 Span::raw(" "),
238 Span::styled(
239 right,
240 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
241 ),
242 ])
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::tui_vt::main_loop::RenderState;
249 use oxicode_vtui::tui::core::InlineHeaderContext;
250 use ratatui::{Terminal, backend::TestBackend};
251
252 fn render_to_string(state: &RenderState, w: u16, h: u16) -> String {
253 let backend = TestBackend::new(w, h);
254 let mut terminal = Terminal::new(backend).expect("backend");
255 terminal
256 .draw(|f| {
257 let _ = render_chrome(f, f.area(), state);
258 })
259 .expect("draw");
260 let buf = terminal.backend().buffer();
261 let area = buf.area();
262 let mut out = String::new();
263 for y in 0..area.height {
264 for x in 0..area.width {
265 if let Some(cell) = buf.cell((x, y)) {
266 out.push_str(cell.symbol());
267 }
268 }
269 out.push('\n');
270 }
271 out
272 }
273
274 #[test]
275 fn chrome_paints_status_bar_and_shortcuts_bar() {
276 let mut state = RenderState::default();
279 state.header_context = InlineHeaderContext::default();
280 state.header_context.model = "smoke-model".to_string();
281
282 let rendered = render_to_string(&state, 80, 24);
283
284 assert!(
286 rendered.contains("smoke-model"),
287 "status bar must render the header model name"
288 );
289 assert!(
291 rendered.contains("send"),
292 "shortcuts bar must show Enter:send"
293 );
294 assert!(
295 rendered.contains("interrupt"),
296 "shortcuts bar must show Ctrl+C:interrupt"
297 );
298 assert!(
299 rendered.contains("cancel"),
300 "shortcuts bar must show Esc:cancel"
301 );
302 }
303
304 #[test]
305 fn chrome_respects_short_terminal_without_panic() {
306 let mut state = RenderState::default();
308 state.header_context = InlineHeaderContext::default();
309 let rendered = render_to_string(&state, 60, 12);
310 assert!(rendered.contains("send"));
311 }
312}