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//! `compute_chrome` computes the [`AgentViewLayout`] for the current
5//! frame. There is no chrome row at all anymore — the static shortcuts
6//! bar was removed (peer parity with Claude Code / omp: static hints
7//! are read once and then waste a row forever). Session facts live on
8//! the composer's top border; abort/quit hints appear contextually in
9//! the row above the composer while a stream runs.
10use oxicode_vtui::design::layout::{
11    AgentViewLayout, LayoutConfig, LayoutInput, ScrollbarConfig, effective_compact,
12};
13use ratatui::layout::Rect;
14
15/// Prompt composer height (matches `main_loop::COMPOSER_HEIGHT`).
16const COMPOSER_HEIGHT: u16 = 3;
17/// No shortcuts bar — 0 height removes it from the layout entirely.
18const SHORTCUTS_HEIGHT: u16 = 0;
19
20/// The live chat surface is intentionally denser than the generic vtui
21/// defaults: it should reserve space for conversation, not decorative frame
22/// padding.  A single horizontal gutter keeps text off the terminal edge;
23/// vertical gutters and their implied separator rows are unnecessary here.
24const CHAT_LAYOUT: LayoutConfig = LayoutConfig {
25    hpad_left: 1,
26    hpad_right: 1,
27    hpad_left_compact: 1,
28    hpad_right_compact: 1,
29    outer_vpad: 0,
30    outer_vpad_compact: 0,
31};
32
33// ─────────────────────────────────────────────────────────────────────────
34// Chrome layout
35// ─────────────────────────────────────────────────────────────────────────
36
37/// Compute the agent view layout. The caller places the transcript into
38/// `layout.scrollback` and the composer into `layout.prompt`.
39/// One blank row kept between the transcript and the composer. The gap is
40/// LAYOUT, not content — a breath row inside the scrolling display gets
41/// windowed away exactly when the transcript is full, gluing the latest
42/// response to the composer. Reserved here, it survives every height and
43/// every scrollback commit; the streaming indicator / quit hint render
44/// into it while a run is live.
45const BREATH_ROW: u16 = 1;
46
47/// Compute the agent view layout. The caller places the transcript into
48/// `layout.scrollback` and the composer into `layout.prompt`.
49pub(super) fn compute_chrome(area: Rect) -> AgentViewLayout {
50    let compact = effective_compact(false, area.height);
51    let mut layout = AgentViewLayout::compute(
52        area,
53        &CHAT_LAYOUT,
54        &ScrollbarConfig {
55            enabled: false,
56            ..Default::default()
57        },
58        LayoutInput {
59            prompt_height: COMPOSER_HEIGHT,
60            shortcuts_height: SHORTCUTS_HEIGHT,
61            compact,
62            ..LayoutInput::default()
63        },
64    );
65    layout.scrollback.height = layout.scrollback.height.saturating_sub(BREATH_ROW);
66    layout
67}
68
69/// Transcript (scrollback) area height for a terminal of this size,
70/// without rendering. The scrollback-commit path needs the live
71/// region's height to decide how many rows to shed into the host
72/// terminal's real scrollback.
73pub(super) fn scrollback_height(area: Rect) -> u16 {
74    let compact = effective_compact(false, area.height);
75    AgentViewLayout::compute(
76        area,
77        &CHAT_LAYOUT,
78        &ScrollbarConfig {
79            enabled: false,
80            ..Default::default()
81        },
82        LayoutInput {
83            prompt_height: COMPOSER_HEIGHT,
84            shortcuts_height: SHORTCUTS_HEIGHT,
85            compact,
86            ..LayoutInput::default()
87        },
88    )
89    .scrollback
90    .height
91    .saturating_sub(BREATH_ROW)
92}
93
94/// Transcript (scrollback) area x/width for a terminal of this size,
95/// without rendering. The layout insets the terminal by one gutter
96/// column on each side (`CHAT_LAYOUT.hpad_*`), so tool boxes and
97/// scrollback commits must size themselves to THIS width — not the
98/// terminal width — or their right edges wrap in the live viewport.
99pub(super) fn scrollback_geometry(area: Rect) -> (u16, u16) {
100    let compact = effective_compact(false, area.height);
101    let layout = AgentViewLayout::compute(
102        area,
103        &CHAT_LAYOUT,
104        &ScrollbarConfig {
105            enabled: false,
106            ..Default::default()
107        },
108        LayoutInput {
109            prompt_height: COMPOSER_HEIGHT,
110            shortcuts_height: SHORTCUTS_HEIGHT,
111            compact,
112            ..LayoutInput::default()
113        },
114    );
115    (layout.scrollback.x, layout.scrollback.width)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn chrome_has_no_top_status_row() {
124        let layout = compute_chrome(Rect {
125            x: 0,
126            y: 0,
127            width: 80,
128            height: 24,
129        });
130
131        // The scrollback IS the top of the frame — no chrome row above
132        // it, and the removed shortcuts bar's row now belongs to the
133        // transcript.
134        assert_eq!(layout.scrollback.y, 0, "scrollback starts at row 0");
135        assert_eq!(
136            layout.scrollback.height,
137            24 - COMPOSER_HEIGHT - SHORTCUTS_HEIGHT - BREATH_ROW,
138            "scrollback owns every row the composer does not, minus the breath row"
139        );
140        assert_eq!(
141            layout.scrollback.bottom() + 1,
142            layout.prompt.y,
143            "the breath row separates transcript from composer"
144        );
145        assert_eq!(SHORTCUTS_HEIGHT, 0, "the static shortcuts bar is gone");
146    }
147
148    #[test]
149    fn chrome_respects_short_terminal_without_panic() {
150        // A short terminal must still lay out (degrades gracefully).
151        let layout = compute_chrome(Rect {
152            x: 0,
153            y: 0,
154            width: 60,
155            height: 12,
156        });
157        assert!(layout.scrollback.height > 0, "transcript keeps space");
158        assert!(layout.prompt.height > 0, "composer keeps space");
159    }
160}