Skip to main content

oxicode/tui_vt/
frame_layout.rs

1//! Production bridge from the grok-build-style agent view layout
2//! (`oxicode_vtui::design::layout`) to the live ratatui render path.
3//!
4//! `render_chrome` computes the [`AgentViewLayout`] for the current frame and
5//! renders the top [`StatusBar`] (header context + footer status) and the
6//! bottom [`ShortcutsBar`] (keyboard hints). It returns the layout so the
7//! caller can place the transcript and composer into `scrollback` / `prompt`.
8//!
9//! All keyboard hints advertised by the shortcuts bar are verified against the
10//! real key dispatch in `super::main_loop::spawn_input_thread` — a hint that
11//! does not match a real handler is a misleading-UI defect.
12use 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
26/// Prompt composer height (matches `main_loop::COMPOSER_HEIGHT`).
27const COMPOSER_HEIGHT: u16 = 3;
28/// Shortcuts bar height (1 row).
29const SHORTCUTS_HEIGHT: u16 = 1;
30
31// ─────────────────────────────────────────────────────────────────────────
32// Color helper (mirrors `main_loop::color_from_anstyle` — kept local so this
33// module is self-contained and `main_loop` needs no extra `pub` edits).
34// ─────────────────────────────────────────────────────────────────────────
35
36fn 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
67// ─────────────────────────────────────────────────────────────────────────
68// ShortcutBarStyling bridge
69// ─────────────────────────────────────────────────────────────────────────
70
71/// Bridges [`ThemeStyles`] to [`ShortcutBarStyling`] without the widgets
72/// reaching into a concrete theme type.
73struct 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
103// ─────────────────────────────────────────────────────────────────────────
104// Keyboard hints (verified against spawn_input_thread key dispatch)
105// ─────────────────────────────────────────────────────────────────────────
106
107/// Build the hint list for the shortcuts bar.
108///
109/// Every hint here corresponds to a real `KeyCode` → `InlineEvent` mapping in
110/// `spawn_input_thread`. Do not add a hint without a matching handler.
111fn 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
122// ─────────────────────────────────────────────────────────────────────────
123// Chrome rendering
124// ─────────────────────────────────────────────────────────────────────────
125
126/// Compute the agent view layout and render the top status bar + bottom
127/// shortcuts bar. Returns the layout so the caller places the transcript into
128/// `layout.scrollback` and the composer into `layout.prompt`.
129pub(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    // ── Status bar (replaces render_header + render_footer's status line) ──
153    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    // ── Shortcuts bar ──
160    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
178/// Header context line: app · provider — model · git · tools.
179fn 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
210/// Right-aligned footer status (left status + line position).
211fn footer_line<'a>(state: &'a RenderState, styles: &ThemeStyles, area: Rect) -> Line<'a> {
212    let left = state.footer_left.clone().unwrap_or_default();
213    // `footer_right` is set by the app (SetInputStatus); only fall back to a
214    // computed line-position when the app has not supplied one. The viewport
215    // is the scrollback height so the position matches what render_transcript
216    // actually displays (the old footer used its own 1-row height, which was
217    // inconsistent with the transcript scroll).
218    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        // Default state is enough — we only assert the chrome regions render,
277        // not specific header content (theme/header fields are app-supplied).
278        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        // StatusBar carries the model name.
285        assert!(
286            rendered.contains("smoke-model"),
287            "status bar must render the header model name"
288        );
289        // ShortcutsBar carries the verified keyboard hints.
290        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        // A short terminal must still render (layout degrades gracefully).
307        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}