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/// The live chat surface is intentionally denser than the generic vtui
32/// defaults: it should reserve space for conversation, not decorative frame
33/// padding.  A single horizontal gutter keeps text off the terminal edge;
34/// vertical gutters and their implied separator rows are unnecessary here.
35const 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
44// ─────────────────────────────────────────────────────────────────────────
45// Color helper (mirrors `main_loop::color_from_anstyle` — kept local so this
46// module is self-contained and `main_loop` needs no extra `pub` edits).
47// ─────────────────────────────────────────────────────────────────────────
48
49fn 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
80// ─────────────────────────────────────────────────────────────────────────
81// ShortcutBarStyling bridge
82// ─────────────────────────────────────────────────────────────────────────
83
84/// Bridges [`ThemeStyles`] to [`ShortcutBarStyling`] without the widgets
85/// reaching into a concrete theme type.
86struct 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
116// ─────────────────────────────────────────────────────────────────────────
117// Keyboard hints (verified against spawn_input_thread key dispatch)
118// ─────────────────────────────────────────────────────────────────────────
119
120/// Build the hint list for the shortcuts bar.
121///
122/// Every hint here corresponds to a real `KeyCode` → `InlineEvent` mapping in
123/// `spawn_input_thread`. Do not add a hint without a matching handler.
124fn 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
135// ─────────────────────────────────────────────────────────────────────────
136// Chrome rendering
137// ─────────────────────────────────────────────────────────────────────────
138
139/// Compute the agent view layout and render the top status bar + bottom
140/// shortcuts bar. Returns the layout so the caller places the transcript into
141/// `layout.scrollback` and the composer into `layout.prompt`.
142pub(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    // ── Status bar (replaces render_header + render_footer's status line) ──
166    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    // ── Shortcuts bar ──
173    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
191/// Quiet application chrome. Detailed session facts belong on the composer's
192/// top border, immediately beside the place where the user acts; keeping this
193/// row quiet avoids showing the model, path, and branch twice.
194fn 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
221/// Right-aligned footer status (left status + line position).
222fn footer_line<'a>(state: &'a RenderState, styles: &ThemeStyles, area: Rect) -> Line<'a> {
223    let left = state.footer_left.clone().unwrap_or_default();
224    // `footer_right` is set by the app (SetInputStatus); only fall back to a
225    // computed line-position when the app has not supplied one. The viewport
226    // is the scrollback height so the position matches what render_transcript
227    // actually displays (the old footer used its own 1-row height, which was
228    // inconsistent with the transcript scroll).
229    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        // Default state is enough — we only assert the chrome regions render,
288        // not specific header content (theme/header fields are app-supplied).
289        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        // Status bar is quiet chrome: app badge, workspace, run status.
296        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        // The model name deliberately does NOT render here — it lives on the
305        // composer's top border so it is not shown twice.
306        assert!(
307            !rendered.contains("smoke-model"),
308            "status bar must not duplicate the composer-border model name"
309        );
310        // ShortcutsBar carries the verified keyboard hints.
311        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        // A short terminal must still render (layout degrades gracefully).
328        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}