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}