zero_tui/app/render.rs
1//! Top-level render — composes status bar, prompt, and the
2//! per-mode pane into a single frame.
3
4use chrono::{DateTime, Utc};
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7
8use std::time::Instant;
9
10use crate::app::mode::Mode;
11use crate::app::state::{ActiveOverlay, AppState};
12use crate::widgets::conversation::ConversationPane;
13use crate::widgets::live_stream::LiveStreamPane;
14use crate::widgets::overlay::{FrictionPauseOverlay, RiskOverlay, StateOverlay, VerdictOverlay};
15use crate::widgets::pane::{CockpitPane, DecisionsPane, HeatPane, PositionsPane};
16use crate::widgets::picker::{PickerWidget, picker_rows};
17use crate::widgets::prompt::PromptWidget;
18use crate::widgets::statusbar::StatusBar;
19
20/// Rows reserved for the live-stream pane when visible. Eight
21/// rows = one header + seven event rows, comfortable on an 80×24
22/// terminal without starving the conversation pane. Kept here
23/// rather than on the widget so the layout math is the single
24/// source of truth.
25pub const LIVE_STREAM_ROWS: u16 = 8;
26
27/// Minimum mode-area height we will retain before collapsing the
28/// live-stream pane in a cramped terminal. Below this, we hide
29/// the pane for this frame — the operator keeps their conversation
30/// view and the `live_stream_visible` flag is untouched (so the
31/// pane reappears as soon as the terminal grows).
32const MIN_MODE_ROWS_WITH_STREAM: u16 = 6;
33
34pub fn render(frame: &mut Frame<'_>, state: &AppState) {
35 render_at(frame, state, Utc::now());
36}
37
38/// Render with an explicit wall-clock instant, used by snapshot
39/// tests to produce a stable `feed:<age>s` string.
40/// Maximum visible prompt rows. The buffer can hold more (capped
41/// at `prompt::MAX_LINES`) but we never let the prompt eat more
42/// than this many rows of screen real estate — the conversation
43/// pane is the more important surface. Operators with a 6+ line
44/// draft typically want to send it; the prompt scrolls internally
45/// at that point (and we draw a "…" continuation indicator that
46/// lands with the wrap pass — for M1, deeper drafts simply cap
47/// the visible portion at the top).
48const MAX_VISIBLE_PROMPT_ROWS: u16 = 6;
49
50pub fn render_at(frame: &mut Frame<'_>, state: &AppState, now: DateTime<Utc>) {
51 let size = frame.area();
52 let prompt_rows = u16::try_from(state.prompt.height())
53 .unwrap_or(u16::MAX)
54 .clamp(1, MAX_VISIBLE_PROMPT_ROWS);
55 // Picker rows — reserved above the prompt so the picker sits
56 // directly between the conversation pane and the prompt. No
57 // rows allocated when there is no active picker; the mode
58 // pane reclaims the space.
59 let picker_rows_needed = state
60 .picker
61 .as_ref()
62 .map_or(0, picker_rows)
63 .min(MAX_VISIBLE_PICKER_ROWS);
64 let chunks = Layout::default()
65 .direction(Direction::Vertical)
66 .constraints([
67 Constraint::Min(1), // mode pane
68 Constraint::Length(picker_rows_needed), // picker (0..=N)
69 Constraint::Length(prompt_rows), // prompt (1..=N)
70 Constraint::Length(1), // status bar
71 ])
72 .split(size);
73
74 // If the live-stream pane is visible AND the mode area has
75 // room for both a meaningful conversation view and the full
76 // pane, split vertically. Otherwise the mode area takes the
77 // entire height (degrades gracefully on cramped terminals
78 // without losing the operator's toggled preference).
79 let mode_area = chunks[0];
80 let (mode_rect, live_stream_rect) = if state.live_stream_visible
81 && mode_area.height.saturating_sub(LIVE_STREAM_ROWS) >= MIN_MODE_ROWS_WITH_STREAM
82 {
83 let split = Layout::default()
84 .direction(Direction::Vertical)
85 .constraints([
86 Constraint::Min(MIN_MODE_ROWS_WITH_STREAM),
87 Constraint::Length(LIVE_STREAM_ROWS),
88 ])
89 .split(mode_area);
90 (split[0], Some(split[1]))
91 } else {
92 (mode_area, None)
93 };
94
95 render_mode(frame, state, mode_rect);
96
97 if let Some(area) = live_stream_rect {
98 frame.render_widget(
99 LiveStreamPane {
100 ring: &state.event_ring,
101 theme: state.theme,
102 },
103 area,
104 );
105 }
106
107 // Modal overlays paint on top of the mode pane only — status
108 // bar + prompt stay visible so the operator never loses the
109 // live engine reading while reading a modal. Overlays use the
110 // full mode area (pre-split) so they stay centered and legible
111 // even when the live-stream pane is carved out.
112 if let Some(ov) = state.overlay.as_ref() {
113 render_overlay(frame, state, ov, mode_area, now);
114 }
115
116 if picker_rows_needed > 0
117 && let Some(picker) = state.picker.as_ref()
118 {
119 frame.render_widget(
120 PickerWidget {
121 picker,
122 theme: state.theme,
123 },
124 chunks[1],
125 );
126 }
127
128 let prompt = PromptWidget {
129 prompt: &state.prompt,
130 theme: state.theme,
131 };
132 let (cursor_col, cursor_row) = prompt.cursor_position();
133 frame.render_widget(prompt, chunks[2]);
134 // Clamp the cursor row to the visible window — if the buffer
135 // grew past `MAX_VISIBLE_PROMPT_ROWS`, the cursor lands on the
136 // last visible row rather than off-screen.
137 let visible_cursor_row = cursor_row.min(prompt_rows.saturating_sub(1));
138 frame.set_cursor_position((chunks[2].x + cursor_col, chunks[2].y + visible_cursor_row));
139
140 let engine_snapshot = state.engine.read().clone();
141 // Re-read the CLI-side rate bucket every frame. The
142 // snapshot is O(µs) (Mutex lock + a clock tick), so paying
143 // for it on each render is cheaper than routing the field
144 // through the engine mirror — and the bucket is a CLI
145 // process handle, not engine state, so `EngineState` is
146 // the wrong home for it. The bucket handle on `AppState`
147 // is `Clone`'d from `HttpClient::rate_budget()` at app
148 // construction (see `zero::run_tui`).
149 let rate_budget = state
150 .rate_budget
151 .as_ref()
152 .map(zero_engine_client::RateBudget::snapshot);
153 let status = StatusBar {
154 mode: state.mode,
155 engine: &engine_snapshot,
156 theme: state.theme,
157 now,
158 rate_budget,
159 };
160 frame.render_widget(status, chunks[3]);
161}
162
163/// Hard ceiling on picker rows. `PICKER_MAX_VISIBLE` in
164/// [`crate::app::picker`] is the semantic cap; this constant is a
165/// layout-level guard that the picker never steals more than 6
166/// conversation rows even if the catalog grows.
167const MAX_VISIBLE_PICKER_ROWS: u16 = 6;
168
169fn render_overlay(
170 frame: &mut Frame<'_>,
171 state: &AppState,
172 ov: &ActiveOverlay,
173 area: Rect,
174 now: DateTime<Utc>,
175) {
176 match ov {
177 ActiveOverlay::State => {
178 let snap = state.engine.read().clone();
179 frame.render_widget(
180 StateOverlay {
181 engine: &snap,
182 theme: state.theme,
183 now,
184 },
185 area,
186 );
187 }
188 ActiveOverlay::FrictionPause(fp) => {
189 frame.render_widget(
190 FrictionPauseOverlay {
191 pause: fp,
192 theme: state.theme,
193 // Render path uses monotonic Instant for pause
194 // arithmetic — the wall-clock `now` is only used
195 // for the state overlay's as-of age.
196 now: Instant::now(),
197 },
198 area,
199 );
200 }
201 ActiveOverlay::Verdict(eval) => {
202 frame.render_widget(
203 VerdictOverlay {
204 evaluation: eval.as_ref(),
205 theme: state.theme,
206 },
207 area,
208 );
209 }
210 ActiveOverlay::Risk { trigger, .. } => {
211 let snap = state.engine.read().clone();
212 frame.render_widget(
213 RiskOverlay {
214 engine: &snap,
215 trigger: *trigger,
216 theme: state.theme,
217 now,
218 },
219 area,
220 );
221 }
222 }
223}
224
225fn render_mode(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
226 match state.mode {
227 Mode::Conversation => {
228 let pane = ConversationPane {
229 log: &state.log,
230 theme: state.theme,
231 scroll: state.log_scroll,
232 screen_reader: state.screen_reader,
233 verbose: state.verbose,
234 };
235 frame.render_widget(pane, area);
236 }
237 Mode::Positions => {
238 let snap = state.engine.read().clone();
239 frame.render_widget(
240 PositionsPane {
241 engine: &snap,
242 theme: state.theme,
243 },
244 area,
245 );
246 }
247 Mode::Decisions => {
248 frame.render_widget(DecisionsPane { theme: state.theme }, area);
249 }
250 Mode::Heat => {
251 let snap = state.engine.read().clone();
252 frame.render_widget(
253 HeatPane {
254 engine: &snap,
255 theme: state.theme,
256 },
257 area,
258 );
259 }
260 Mode::Cockpit => {
261 let snap = state.engine.read().clone();
262 frame.render_widget(
263 CockpitPane {
264 engine: &snap,
265 theme: state.theme,
266 },
267 area,
268 );
269 }
270 }
271}