Skip to main content

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}