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
31const CHAT_LAYOUT: LayoutConfig = LayoutConfig {
36 hpad_left: 1,
37 hpad_right: 1,
38 hpad_left_compact: 1,
39 hpad_right_compact: 1,
40 outer_vpad: 0,
41 outer_vpad_compact: 0,
42};
43
44fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
50 match color {
51 Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
52 Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
53 Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
54 None => Color::Reset,
55 }
56}
57
58fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
59 use anstyle::AnsiColor as A;
60 match color {
61 A::Black => Color::Black,
62 A::Red => Color::Red,
63 A::Green => Color::Green,
64 A::Yellow => Color::Yellow,
65 A::Blue => Color::Blue,
66 A::Magenta => Color::Magenta,
67 A::Cyan => Color::Cyan,
68 A::White => Color::Gray,
69 A::BrightBlack => Color::DarkGray,
70 A::BrightRed => Color::LightRed,
71 A::BrightGreen => Color::LightGreen,
72 A::BrightYellow => Color::LightYellow,
73 A::BrightBlue => Color::LightBlue,
74 A::BrightMagenta => Color::LightMagenta,
75 A::BrightCyan => Color::LightCyan,
76 A::BrightWhite => Color::White,
77 }
78}
79
80struct ThemeShortcutStyles<'a> {
87 styles: &'a ThemeStyles,
88}
89
90impl ShortcutBarStyling for ThemeShortcutStyles<'_> {
91 fn key_style(&self) -> Style {
92 Style::default()
93 .fg(color_from_anstyle(self.styles.primary.get_fg_color()))
94 .add_modifier(Modifier::BOLD)
95 }
96
97 fn label_style(&self) -> Style {
98 Style::default().fg(color_from_anstyle(Some(self.styles.foreground)))
99 }
100
101 fn separator_style(&self) -> Style {
102 Style::default().fg(color_from_anstyle(self.styles.secondary.get_fg_color()))
103 }
104
105 fn background_style(&self) -> Style {
106 Style::default().bg(color_from_anstyle(Some(self.styles.background)))
107 }
108
109 fn pending_key_style(&self) -> Style {
110 Style::default()
111 .fg(color_from_anstyle(self.styles.error.get_fg_color()))
112 .add_modifier(Modifier::BOLD)
113 }
114}
115
116fn shortcut_hints() -> Vec<HintItem> {
125 vec![
126 HintItem::new("Tab", "complete"),
127 HintItem::new("Enter", "send").pinned(),
128 HintItem::new("Esc", "cancel").pinned(),
129 HintItem::new("Ctrl+C", "interrupt"),
130 HintItem::paired("Up", "Down", "scroll"),
131 HintItem::paired("PgUp", "PgDn", "page"),
132 ]
133}
134
135pub(super) fn render_chrome(
143 frame: &mut Frame<'_>,
144 area: Rect,
145 state: &RenderState,
146) -> AgentViewLayout {
147 let styles = active_styles();
148 let compact = effective_compact(false, area.height);
149
150 let layout = AgentViewLayout::compute(
151 area,
152 &CHAT_LAYOUT,
153 &ScrollbarConfig {
154 enabled: false,
155 ..Default::default()
156 },
157 LayoutInput {
158 prompt_height: COMPOSER_HEIGHT,
159 shortcuts_height: SHORTCUTS_HEIGHT,
160 compact,
161 ..LayoutInput::default()
162 },
163 );
164
165 let bg = color_from_anstyle(Some(styles.background));
167 let status = StatusBar::new(header_line(state, &styles))
168 .right(footer_line(state, &styles, layout.scrollback))
169 .style(Style::default().bg(bg));
170 frame.render_widget(status, layout.status_bar);
171
172 let hints = shortcut_hints();
174 let shortcut_styles = ThemeShortcutStyles { styles: &styles };
175 let mut bar = ShortcutsBar::new(&hints, &shortcut_styles);
176 if state.pending_quit {
177 bar = bar.pending(PendingHint {
178 key: "Ctrl+C",
179 label: "quit",
180 });
181 }
182 let compact_cfg = CompactConfig::default();
183 if compact {
184 bar = bar.compact(&compact_cfg);
185 }
186 frame.render_widget(bar, layout.shortcuts);
187
188 layout
189}
190
191fn header_line<'a>(state: &'a RenderState, styles: &ThemeStyles) -> Line<'a> {
195 let ctx = &state.header_context;
196 let fg = color_from_anstyle(Some(styles.foreground));
197 let bg = color_from_anstyle(Some(styles.background));
198 let secondary = color_from_anstyle(styles.secondary.get_fg_color());
199 let info = color_from_anstyle(styles.info.get_fg_color());
200
201 let workspace = ctx
202 .search_tools
203 .as_ref()
204 .map(|badge| badge.text.as_str())
205 .filter(|name| !name.is_empty())
206 .unwrap_or("workspace");
207 let run_status = state.reasoning_stage.as_deref().unwrap_or("ready");
208 Line::from(vec![
209 Span::styled(
210 format!(" {} ", ctx.app_name.to_ascii_uppercase()),
211 Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
212 ),
213 Span::styled(format!(" {workspace} "), Style::default().fg(secondary)),
214 Span::raw(" | "),
215 Span::styled(run_status.to_string(), Style::default().fg(info)),
216 Span::raw(" | "),
217 Span::styled(ctx.tools.clone(), Style::default().fg(secondary)),
218 ])
219}
220
221fn footer_line<'a>(state: &'a RenderState, styles: &ThemeStyles, area: Rect) -> Line<'a> {
223 let left = state.footer_left.clone().unwrap_or_default();
224 let right = state.footer_right.clone().unwrap_or_else(|| {
230 let total = state.transcript.len();
231 if state.scroll_offset == usize::MAX {
232 format!("line {total}/{total}")
233 } else {
234 let off = super::main_loop::effective_scroll_offset(
235 state.scroll_offset,
236 total,
237 area.height as usize,
238 );
239 format!("line {}/{}", off.min(total), total)
240 }
241 });
242
243 Line::from(vec![
244 Span::styled(
245 left,
246 Style::default().fg(color_from_anstyle(Some(styles.foreground))),
247 ),
248 Span::raw(" "),
249 Span::styled(
250 right,
251 Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
252 ),
253 ])
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::tui_vt::main_loop::RenderState;
260 use oxicode_vtui::tui::core::InlineHeaderContext;
261 use ratatui::{Terminal, backend::TestBackend};
262
263 fn render_to_string(state: &RenderState, w: u16, h: u16) -> String {
264 let backend = TestBackend::new(w, h);
265 let mut terminal = Terminal::new(backend).expect("backend");
266 terminal
267 .draw(|f| {
268 let _ = render_chrome(f, f.area(), state);
269 })
270 .expect("draw");
271 let buf = terminal.backend().buffer();
272 let area = buf.area();
273 let mut out = String::new();
274 for y in 0..area.height {
275 for x in 0..area.width {
276 if let Some(cell) = buf.cell((x, y)) {
277 out.push_str(cell.symbol());
278 }
279 }
280 out.push('\n');
281 }
282 out
283 }
284
285 #[test]
286 fn chrome_paints_status_bar_and_shortcuts_bar() {
287 let mut state = RenderState::default();
290 state.header_context = InlineHeaderContext::default();
291 state.header_context.model = "smoke-model".to_string();
292
293 let rendered = render_to_string(&state, 80, 24);
294
295 assert!(
297 rendered.contains("APP") && rendered.contains("workspace"),
298 "status bar must render the app badge and workspace"
299 );
300 assert!(
301 rendered.contains("ready"),
302 "status bar must render the run status"
303 );
304 assert!(
307 !rendered.contains("smoke-model"),
308 "status bar must not duplicate the composer-border model name"
309 );
310 assert!(
312 rendered.contains("send"),
313 "shortcuts bar must show Enter:send"
314 );
315 assert!(
316 rendered.contains("interrupt"),
317 "shortcuts bar must show Ctrl+C:interrupt"
318 );
319 assert!(
320 rendered.contains("cancel"),
321 "shortcuts bar must show Esc:cancel"
322 );
323 }
324
325 #[test]
326 fn chrome_respects_short_terminal_without_panic() {
327 let mut state = RenderState::default();
329 state.header_context = InlineHeaderContext::default();
330 let rendered = render_to_string(&state, 60, 12);
331 assert!(rendered.contains("send"));
332 }
333}