Skip to main content

zeph_tui/app/
draw.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ratatui::layout::Rect;
5
6use crate::layout::AppLayout;
7use crate::widgets;
8use crate::widgets::wave::EqualizerWidget;
9
10use super::{App, Panel};
11
12impl App {
13    pub fn draw(&mut self, frame: &mut ratatui::Frame) {
14        // Height of the equalizer slot carved from the bottom of the subagents panel.
15        const EQ_PANEL_H: u16 = 4;
16
17        let collapsed = self.effective_collapsed();
18        let mut layout = AppLayout::compute(
19            frame.area(),
20            self.show_side_panels,
21            self.desired_input_height(),
22            collapsed,
23        );
24
25        // Micro-delight state is advanced in tick_delights() (called on AppEvent::Tick).
26        // draw() only reads the current state for rendering.
27        let now = self.anim_tick();
28        let cur_show_splash = self.sessions.current().show_splash;
29        let shimmer_enabled =
30            self.motion != zeph_config::Motion::Off && self.delights.splash_shimmer;
31
32        self.draw_header(frame, layout.header);
33        if cur_show_splash {
34            let shimmer_phase = if shimmer_enabled {
35                self.splash_shimmer.phase(now)
36            } else {
37                None
38            };
39            widgets::splash::render(
40                frame,
41                layout.chat,
42                self.effective_color_mode(),
43                shimmer_phase,
44            );
45        } else {
46            let mut cache = std::mem::take(&mut self.sessions.current_mut().render_cache);
47            let max_scroll = widgets::chat::render(self, frame, layout.chat, &mut cache);
48            self.sessions.current_mut().render_cache = cache;
49            self.sessions.current_mut().scroll_offset =
50                self.sessions.current().scroll_offset.min(max_scroll);
51        }
52        self.draw_separator(frame, layout.separator);
53
54        // Carve the equalizer slot from the bottom of the subagents area. The slot
55        // appears while the agent is busy OR background/external requests are inflight
56        // (so concurrent background work is visible), unless the user has hidden it.
57        let wave_state = self.wave_state();
58        let wave_tick = self.wave_tick();
59        let wave_active = self.is_agent_busy() || self.background_inflight() > 0;
60        let eq_area =
61            if self.show_equalizer && wave_active && layout.subagents.height > EQ_PANEL_H + 2 {
62                let sub_h = layout.subagents.height - EQ_PANEL_H;
63                let eq = Rect {
64                    y: layout.subagents.y + sub_h,
65                    height: EQ_PANEL_H,
66                    ..layout.subagents
67                };
68                layout.subagents = Rect {
69                    height: sub_h,
70                    ..layout.subagents
71                };
72                eq
73            } else {
74                Rect::default()
75            };
76
77        self.draw_side_panel(frame, &layout, collapsed);
78
79        if eq_area.height > 0 {
80            frame.render_widget(
81                EqualizerWidget {
82                    state: wave_state,
83                    tick: wave_tick,
84                    theme: &self.theme,
85                    color_mode: self.effective_color_mode(),
86                    ascii_only: self.is_ascii_only(),
87                },
88                eq_area,
89            );
90        }
91
92        let spinner_idx = self.throbber_state().index().cast_unsigned();
93        let busy = self.is_agent_busy();
94        let motion = self.motion();
95        widgets::input::render(self, frame, layout.input, busy, spinner_idx, motion);
96        widgets::status::render(self, &self.metrics, frame, layout.status);
97
98        if let Some(state) = &self.file_picker_state {
99            widgets::file_picker::render(state, frame, layout.input, &self.theme);
100        }
101
102        if let Some(state) = &self.slash_autocomplete {
103            widgets::slash_autocomplete::render(state, frame, layout.input, &self.theme);
104        }
105
106        if let Some(state) = &self.reverse_search {
107            let history = self.sessions.current().input_history.clone();
108            widgets::reverse_search::render(state, &history, frame, layout.input, &self.theme);
109        }
110
111        if let Some(state) = &self.transcript_search {
112            widgets::transcript_search::render(state, frame, layout.input, &self.theme);
113        }
114
115        // Render toasts above the input, below modal overlays.
116        if self.motion != zeph_config::Motion::Off && self.delights.toasts {
117            widgets::toast::render(&self.toasts, frame, layout.chat, &self.theme, now);
118        }
119
120        if let Some(state) = &self.confirm_state {
121            widgets::confirm::render(&state.prompt, frame, frame.area(), &self.theme);
122        }
123
124        if let Some(state) = &self.elicitation_state {
125            widgets::elicitation::render(&state.dialog, frame, frame.area(), &self.theme);
126        }
127
128        if let Some(palette) = &self.command_palette {
129            widgets::command_palette::render(palette, frame, frame.area(), &self.theme);
130        }
131
132        if self.show_help {
133            widgets::help::render(frame, frame.area(), &self.theme);
134        }
135
136        self.last_layout = Some(layout);
137    }
138
139    pub(super) fn draw_header(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
140        use ratatui::style::Modifier;
141        use ratatui::text::{Line, Span};
142        use ratatui::widgets::Paragraph;
143
144        let theme = &self.theme;
145
146        let provider = if self.metrics.provider_name.is_empty() {
147            "---"
148        } else {
149            &self.metrics.provider_name
150        };
151        let model = if self.metrics.model_name.is_empty() {
152            "---"
153        } else {
154            &self.metrics.model_name
155        };
156
157        let ctx_badge = if self.metrics.extended_context {
158            "  1M CTX"
159        } else {
160            ""
161        };
162
163        // Brand name rendered bold, metadata in muted style — no solid background.
164        let brand_style = theme.panel_title.add_modifier(Modifier::BOLD);
165        let meta_style = theme.system_message;
166
167        let meta = format!(
168            "  {provider}  {model}  v{}{}",
169            env!("CARGO_PKG_VERSION"),
170            ctx_badge,
171        );
172
173        let mut spans = vec![
174            Span::styled("≈ ", theme.user_message),
175            Span::styled("zeph", brand_style),
176            Span::styled(meta, meta_style),
177        ];
178
179        // Persistent resume banner (spec-068 §13.5): appended to the same single-row header
180        // line rather than a dedicated row, keeping `AppLayout`'s header height at 1 (OQ-I —
181        // placement is an implementation choice, not a spec constraint). Stays visible after
182        // the first prompt, unlike the transient status/spinner line.
183        if let Some(banner) = &self.resume_banner {
184            spans.push(Span::styled(
185                format!("   {banner}"),
186                theme.system_message.add_modifier(Modifier::ITALIC),
187            ));
188        }
189
190        let line = Line::from(spans);
191
192        // Transparent background: no .style() wrapper that would paint the row.
193        frame.render_widget(Paragraph::new(line), area);
194    }
195
196    fn draw_separator(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
197        use ratatui::text::Line;
198        use ratatui::widgets::Paragraph;
199
200        if area.width == 0 || area.height == 0 {
201            return;
202        }
203        // Fill each row of the separator column with the vertical bar glyph.
204        let rows: Vec<Line<'_>> = (0..area.height)
205            .map(|_| Line::from(ratatui::text::Span::styled("│", self.theme.panel_border)))
206            .collect();
207        frame.render_widget(Paragraph::new(rows), area);
208    }
209
210    fn draw_side_panel(
211        &mut self,
212        frame: &mut ratatui::Frame,
213        layout: &AppLayout,
214        effective: [bool; 4],
215    ) {
216        use ratatui::layout::{Constraint, Direction, Layout};
217
218        let focused_panel = self.active_panel;
219
220        if effective[0] {
221            self.render_collapsed_summary(
222                frame,
223                layout.skills,
224                "skills",
225                focused_panel == super::Panel::Skills,
226            );
227        } else if focused_panel == super::Panel::Skills {
228            self.render_section_header(frame, layout.skills, "skills");
229            let inner = shrink_top(layout.skills, 1);
230            widgets::skills::render(&self.metrics, frame, inner, &self.theme);
231        } else {
232            widgets::skills::render(&self.metrics, frame, layout.skills, &self.theme);
233        }
234
235        if effective[1] {
236            self.render_collapsed_summary(
237                frame,
238                layout.memory,
239                "memory",
240                focused_panel == super::Panel::Memory,
241            );
242        } else if focused_panel == super::Panel::Memory {
243            self.render_section_header(frame, layout.memory, "memory");
244            let inner = shrink_top(layout.memory, 1);
245            widgets::memory::render(&self.metrics, frame, inner, &self.theme);
246        } else {
247            widgets::memory::render(&self.metrics, frame, layout.memory, &self.theme);
248        }
249
250        if effective[2] {
251            self.render_collapsed_summary(
252                frame,
253                layout.resources,
254                "resources",
255                focused_panel == super::Panel::Resources,
256            );
257        } else {
258            let resources_area = if focused_panel == super::Panel::Resources {
259                self.render_section_header(frame, layout.resources, "resources");
260                shrink_top(layout.resources, 1)
261            } else {
262                layout.resources
263            };
264            let splits = Layout::default()
265                .direction(Direction::Vertical)
266                .constraints([
267                    Constraint::Length(1),
268                    Constraint::Length(1),
269                    Constraint::Min(0),
270                ])
271                .split(resources_area);
272            widgets::context_gauge::render(&self.metrics, frame, splits[0], &self.theme);
273            widgets::compaction_badge::render(&self.metrics, frame, splits[1], &self.theme);
274            widgets::resources::render(&self.metrics, frame, splits[2], &self.theme);
275        }
276
277        let tick = self.throbber_state.index().cast_unsigned();
278        let ascii = self.is_ascii_only();
279        let has_graph = self.metrics.orchestration_graph.as_ref().is_some_and(|s| {
280            // Use is_stale() to check if snapshot is too old to show (IC4).
281            !s.is_stale()
282        });
283        let panel_focused = self.active_panel == Panel::SubAgents;
284
285        if effective[3] {
286            self.render_collapsed_summary(
287                frame,
288                layout.subagents,
289                "agents",
290                focused_panel == Panel::SubAgents,
291            );
292        } else {
293            self.render_subagents_slot(
294                frame,
295                layout.subagents,
296                tick,
297                ascii,
298                panel_focused,
299                has_graph,
300            );
301        }
302    }
303
304    fn render_subagents_slot(
305        &mut self,
306        frame: &mut ratatui::Frame,
307        area: ratatui::layout::Rect,
308        tick: u8,
309        ascii: bool,
310        panel_focused: bool,
311        has_graph: bool,
312    ) {
313        use ratatui::text::{Line, Span};
314        use ratatui::widgets::{Clear, Paragraph};
315
316        // When SubAgents panel is focused (`a` key), always show the interactive sidebar.
317        // Otherwise: auto-show plan when graph active, security events, or subagents list.
318        if panel_focused {
319            widgets::subagents::render_interactive(
320                &self.metrics,
321                &mut self.subagent_sidebar,
322                frame,
323                area,
324                tick,
325                &self.theme,
326                ascii,
327            );
328        } else if has_graph && !self.sessions.current().plan_view_active {
329            widgets::plan_view::render(&self.metrics, frame, area, tick, ascii, &self.theme);
330        } else if self.has_recent_security_events() {
331            widgets::security::render(&self.metrics, frame, area, &self.theme);
332        } else {
333            widgets::subagents::render(&self.metrics, frame, area, &self.theme);
334        }
335
336        // Overlay fleet panel over the subagents slot when `f` key is active (#3884).
337        if self.active_panel == Panel::Fleet {
338            widgets::fleet::render(
339                &self.fleet_snapshot,
340                frame,
341                area,
342                &mut self.fleet_list_state,
343                &self.theme,
344            );
345        }
346
347        // Overlay durable panel over the subagents slot when `D` key is active (spec-064, #4949).
348        if self.active_panel == Panel::Durable {
349            widgets::durable::render(
350                &self.durable_snapshot,
351                frame,
352                area,
353                &mut self.durable_list_state,
354                &self.theme,
355            );
356        }
357
358        // Overlay the read-only settings view over the subagents slot when `S` is
359        // active (issue #6024), mirroring the Fleet/Durable overlay precedent.
360        if self.active_panel == Panel::Settings {
361            widgets::settings::render(&self.metrics, &mut self.settings, frame, area, &self.theme);
362        }
363
364        // Overlay task registry over the subagents slot when `/tasks` is toggled.
365        if self.show_task_panel {
366            if self.task_supervisor.is_some() {
367                widgets::task_registry::render(
368                    &self.cached_task_snapshots,
369                    tick,
370                    area,
371                    frame,
372                    &self.theme,
373                    ascii,
374                );
375            } else {
376                let theme = &self.theme;
377                let header = Line::from(vec![
378                    Span::styled("≈ ", theme.highlight),
379                    Span::styled("tasks  supervisor not available", theme.system_message),
380                ]);
381                frame.render_widget(Clear, area);
382                frame.render_widget(Paragraph::new(header), area);
383            }
384        }
385    }
386
387    /// Render a single-row collapsed summary bar for the given panel label.
388    ///
389    /// When `focused` is true the brand glyph prefix and accent color replace the muted style.
390    fn render_collapsed_summary(
391        &self,
392        frame: &mut ratatui::Frame,
393        area: ratatui::layout::Rect,
394        label: &str,
395        focused: bool,
396    ) {
397        use ratatui::text::{Line, Span};
398        use ratatui::widgets::Paragraph;
399
400        if area.height == 0 || area.width == 0 {
401            return;
402        }
403        let line = if focused {
404            Line::from(vec![
405                Span::styled("≈ ", self.theme.highlight),
406                Span::styled(label, self.theme.highlight),
407            ])
408        } else {
409            Line::from(vec![
410                Span::styled("▸ ", self.theme.panel_border),
411                Span::styled(label, self.theme.panel_title),
412            ])
413        };
414        frame.render_widget(Paragraph::new(line), area);
415    }
416
417    /// Render a single-row focused section header (brand glyph + accent color).
418    fn render_section_header(
419        &self,
420        frame: &mut ratatui::Frame,
421        area: ratatui::layout::Rect,
422        label: &str,
423    ) {
424        use ratatui::text::{Line, Span};
425        use ratatui::widgets::Paragraph;
426
427        if area.height == 0 || area.width == 0 {
428            return;
429        }
430        let line = Line::from(vec![
431            Span::styled("≈ ", self.theme.highlight),
432            Span::styled(label, self.theme.highlight),
433        ]);
434        frame.render_widget(Paragraph::new(line), area);
435    }
436}
437
438/// Return `area` with the top `n` rows removed.
439fn shrink_top(area: ratatui::layout::Rect, n: u16) -> ratatui::layout::Rect {
440    if n >= area.height {
441        return ratatui::layout::Rect {
442            x: area.x,
443            y: area.y + area.height,
444            width: area.width,
445            height: 0,
446        };
447    }
448    ratatui::layout::Rect {
449        x: area.x,
450        y: area.y + n,
451        width: area.width,
452        height: area.height - n,
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use ratatui::Terminal;
459    use ratatui::backend::TestBackend;
460    use tokio::sync::mpsc;
461
462    use super::App;
463
464    fn make_app() -> App {
465        let (user_tx, _) = mpsc::channel(1);
466        let (_, agent_rx) = mpsc::channel(1);
467        App::new(user_tx, agent_rx)
468    }
469
470    /// Fills the whole area with a sentinel glyph before calling `render_subagents_slot`, in
471    /// the same frame — mirroring the real bug shape (#6061): the task-panel
472    /// supervisor-unavailable fallback shares its `Rect` with Fleet/Durable/task-registry
473    /// overlays but, before the fix, never called `Clear`, so stale glyphs from whatever
474    /// rendered underneath survived in every cell the fallback `Paragraph` didn't touch.
475    fn render_fallback_over_sentinel(app: &mut App) -> ratatui::buffer::Buffer {
476        let backend = TestBackend::new(80, 10);
477        let mut terminal = Terminal::new(backend).unwrap();
478        terminal
479            .draw(|frame| {
480                let area = frame.area();
481                for y in area.top()..area.bottom() {
482                    for x in area.left()..area.right() {
483                        frame.buffer_mut()[(x, y)].set_symbol("#");
484                    }
485                }
486                app.render_subagents_slot(frame, area, 0, false, false, false);
487            })
488            .unwrap();
489        terminal.backend().buffer().clone()
490    }
491
492    #[test]
493    fn task_panel_fallback_clears_stale_glyphs_when_supervisor_unavailable() {
494        let mut app = make_app();
495        app.show_task_panel = true;
496        assert!(app.task_supervisor.is_none());
497
498        let buf = render_fallback_over_sentinel(&mut app);
499
500        for cell in &buf.content {
501            assert_ne!(
502                cell.symbol(),
503                "#",
504                "stray sentinel glyph survived render — Clear is missing or not applied \
505                 to the whole area"
506            );
507        }
508    }
509}