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, EQ_PANEL_H, Panel};
11
12impl App {
13    pub fn draw(&mut self, frame: &mut ratatui::Frame) {
14        let collapsed = self.effective_collapsed();
15        let mut layout = AppLayout::compute(
16            frame.area(),
17            self.show_side_panels,
18            self.desired_input_height(),
19            self.panel_demands(),
20        );
21
22        // Micro-delight state is advanced in tick_delights() (called on AppEvent::Tick).
23        // draw() only reads the current state for rendering.
24        let now = self.anim_tick();
25        let cur_show_splash = self.sessions.current().show_splash;
26        let shimmer_enabled =
27            self.motion != zeph_config::Motion::Off && self.delights.splash_shimmer;
28
29        self.draw_header(frame, layout.header);
30        if cur_show_splash {
31            let shimmer_phase = if shimmer_enabled {
32                self.splash_shimmer.phase(now)
33            } else {
34                None
35            };
36            widgets::splash::render(
37                frame,
38                layout.chat,
39                self.effective_color_mode(),
40                shimmer_phase,
41            );
42        } else {
43            let mut cache = std::mem::take(&mut self.sessions.current_mut().render_cache);
44            let max_scroll = widgets::chat::render(self, frame, layout.chat, &mut cache);
45            self.sessions.current_mut().render_cache = cache;
46            self.sessions.current_mut().scroll_offset =
47                self.sessions.current().scroll_offset.min(max_scroll);
48        }
49        self.draw_separator(frame, layout.separator);
50
51        // Carve the equalizer slot from the bottom of the subagents area. The slot
52        // appears while the agent is busy OR background/external requests are inflight
53        // (so concurrent background work is visible), unless the user has hidden it.
54        let wave_state = self.wave_state();
55        let wave_tick = self.wave_tick();
56        let wave_active = self.is_agent_busy() || self.background_inflight() > 0;
57        let eq_area =
58            if should_carve_equalizer(self.show_equalizer, wave_active, layout.subagents.height) {
59                let sub_h = layout.subagents.height - EQ_PANEL_H;
60                let eq = Rect {
61                    y: layout.subagents.y + sub_h,
62                    height: EQ_PANEL_H,
63                    ..layout.subagents
64                };
65                layout.subagents = Rect {
66                    height: sub_h,
67                    ..layout.subagents
68                };
69                eq
70            } else {
71                Rect::default()
72            };
73
74        self.draw_side_panel(frame, &layout, collapsed);
75
76        if eq_area.height > 0 {
77            frame.render_widget(
78                EqualizerWidget {
79                    state: wave_state,
80                    tick: wave_tick,
81                    theme: &self.theme,
82                    color_mode: self.effective_color_mode(),
83                    ascii_only: self.is_ascii_only(),
84                },
85                eq_area,
86            );
87        }
88
89        let spinner_idx = self.throbber_state().index().cast_unsigned();
90        let busy = self.is_agent_busy();
91        let motion = self.motion();
92        widgets::input::render(self, frame, layout.input, busy, spinner_idx, motion);
93        widgets::status::render(self, &self.metrics, frame, layout.status);
94
95        if let Some(state) = &self.mention_picker {
96            widgets::mention_picker::render(self, state, frame, layout.input, &self.theme);
97        }
98
99        if let Some(state) = &self.slash_autocomplete {
100            widgets::slash_autocomplete::render(state, frame, layout.input, &self.theme);
101        }
102
103        if let Some(state) = &self.reverse_search {
104            let history = self.sessions.current().input_history.clone();
105            widgets::reverse_search::render(state, &history, frame, layout.input, &self.theme);
106        }
107
108        if let Some(state) = &self.transcript_search {
109            widgets::transcript_search::render(state, frame, layout.input, &self.theme);
110        }
111
112        // Render toasts above the input, below modal overlays.
113        if self.motion != zeph_config::Motion::Off && self.delights.toasts {
114            widgets::toast::render(&self.toasts, frame, layout.chat, &self.theme, now);
115        }
116
117        if let Some(state) = &self.confirm_state {
118            widgets::confirm::render(&state.prompt, frame, frame.area(), &self.theme);
119        }
120
121        if let Some(state) = &self.elicitation_state {
122            widgets::elicitation::render(&state.dialog, frame, frame.area(), &self.theme);
123        }
124
125        if let Some(palette) = &self.command_palette {
126            widgets::command_palette::render(palette, frame, frame.area(), &self.theme);
127        }
128
129        if self.show_help {
130            widgets::help::render(frame, frame.area(), &self.theme);
131        }
132
133        self.last_layout = Some(layout);
134    }
135
136    pub(super) fn draw_header(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
137        use ratatui::style::Modifier;
138        use ratatui::text::{Line, Span};
139        use ratatui::widgets::Paragraph;
140
141        let theme = &self.theme;
142
143        let provider = if self.metrics.provider_name.is_empty() {
144            "---"
145        } else {
146            &self.metrics.provider_name
147        };
148        let model = if self.metrics.model_name.is_empty() {
149            "---"
150        } else {
151            &self.metrics.model_name
152        };
153
154        let ctx_badge = if self.metrics.extended_context {
155            "  1M CTX"
156        } else {
157            ""
158        };
159
160        // Brand name rendered bold, metadata in muted style — no solid background.
161        let brand_style = theme.panel_title.add_modifier(Modifier::BOLD);
162        let meta_style = theme.system_message;
163
164        let meta = format!(
165            "  {provider}  {model}  v{}{}",
166            env!("CARGO_PKG_VERSION"),
167            ctx_badge,
168        );
169
170        let mut spans = vec![
171            Span::styled("≈ ", theme.user_message),
172            Span::styled("zeph", brand_style),
173            Span::styled(meta, meta_style),
174        ];
175
176        // Persistent resume banner (spec-068 §13.5): appended to the same single-row header
177        // line rather than a dedicated row, keeping `AppLayout`'s header height at 1 (OQ-I —
178        // placement is an implementation choice, not a spec constraint). Stays visible after
179        // the first prompt, unlike the transient status/spinner line.
180        if let Some(banner) = &self.resume_banner {
181            spans.push(Span::styled(
182                format!("   {banner}"),
183                theme.system_message.add_modifier(Modifier::ITALIC),
184            ));
185        }
186
187        let line = Line::from(spans);
188
189        // Transparent background: no .style() wrapper that would paint the row.
190        frame.render_widget(Paragraph::new(line), area);
191    }
192
193    fn draw_separator(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect) {
194        use ratatui::text::Line;
195        use ratatui::widgets::Paragraph;
196
197        if area.width == 0 || area.height == 0 {
198            return;
199        }
200        // Fill each row of the separator column with the vertical bar glyph.
201        let rows: Vec<Line<'_>> = (0..area.height)
202            .map(|_| Line::from(ratatui::text::Span::styled("│", self.theme.panel_border)))
203            .collect();
204        frame.render_widget(Paragraph::new(rows), area);
205    }
206
207    fn draw_side_panel(
208        &mut self,
209        frame: &mut ratatui::Frame,
210        layout: &AppLayout,
211        effective: [bool; 4],
212    ) {
213        use ratatui::layout::{Constraint, Direction, Layout};
214
215        let focused_panel = self.active_panel;
216
217        if effective[0] {
218            self.render_collapsed_summary(
219                frame,
220                layout.skills,
221                "skills",
222                focused_panel == super::Panel::Skills,
223            );
224        } else if focused_panel == super::Panel::Skills {
225            self.render_section_header(frame, layout.skills, "skills");
226            let inner = shrink_top(layout.skills, 1);
227            widgets::skills::render(&self.metrics, frame, inner, &self.theme);
228        } else {
229            widgets::skills::render(&self.metrics, frame, layout.skills, &self.theme);
230        }
231
232        if effective[1] {
233            self.render_collapsed_summary(
234                frame,
235                layout.memory,
236                "memory",
237                focused_panel == super::Panel::Memory,
238            );
239        } else if focused_panel == super::Panel::Memory {
240            self.render_section_header(frame, layout.memory, "memory");
241            let inner = shrink_top(layout.memory, 1);
242            widgets::memory::render(&self.metrics, frame, inner, &self.theme);
243        } else {
244            widgets::memory::render(&self.metrics, frame, layout.memory, &self.theme);
245        }
246
247        if effective[2] {
248            self.render_collapsed_summary(
249                frame,
250                layout.resources,
251                "resources",
252                focused_panel == super::Panel::Resources,
253            );
254        } else {
255            let resources_area = if focused_panel == super::Panel::Resources {
256                self.render_section_header(frame, layout.resources, "resources");
257                shrink_top(layout.resources, 1)
258            } else {
259                layout.resources
260            };
261            // The badge row's height must track `compaction_badge::desired_height` exactly —
262            // a fixed Length(1) here would eat a row `panel_demands()` never budgeted for
263            // when no compaction has occurred, shifting `resources::render`'s own content
264            // down by one and desyncing sizing from rendering (#6675).
265            let badge_h = widgets::compaction_badge::desired_height(&self.metrics);
266            let splits = Layout::default()
267                .direction(Direction::Vertical)
268                .constraints([
269                    Constraint::Length(1),
270                    Constraint::Length(badge_h),
271                    Constraint::Min(0),
272                ])
273                .split(resources_area);
274            widgets::context_gauge::render(&self.metrics, frame, splits[0], &self.theme);
275            widgets::compaction_badge::render(&self.metrics, frame, splits[1], &self.theme);
276            widgets::resources::render(&self.metrics, frame, splits[2], &self.theme);
277        }
278
279        let tick = self.throbber_state.index().cast_unsigned();
280        let ascii = self.is_ascii_only();
281
282        if effective[3] {
283            self.render_collapsed_summary(
284                frame,
285                layout.subagents,
286                "agents",
287                focused_panel == Panel::SubAgents,
288            );
289        } else {
290            self.render_subagents_slot(frame, layout.subagents, tick, ascii);
291        }
292    }
293
294    /// Render the `SubAgents` slot's base layer, chosen by `App::subagent_slot_mode`, then
295    /// layer any active overlay (Fleet/Durable/Settings/Tasks) on top of it. The mode
296    /// selection is computed once and shared with sizing (`App::panel_demands`) so the two
297    /// decisions can never disagree (#6675).
298    fn render_subagents_slot(
299        &mut self,
300        frame: &mut ratatui::Frame,
301        area: ratatui::layout::Rect,
302        tick: u8,
303        ascii: bool,
304    ) {
305        use ratatui::text::{Line, Span};
306        use ratatui::widgets::{Clear, Paragraph};
307
308        match self.subagent_slot_mode() {
309            widgets::subagents::SubAgentSlotMode::Interactive => {
310                widgets::subagents::render_interactive(
311                    &self.metrics,
312                    &mut self.subagent_sidebar,
313                    frame,
314                    area,
315                    tick,
316                    &self.theme,
317                    ascii,
318                );
319            }
320            widgets::subagents::SubAgentSlotMode::PlanView => {
321                widgets::plan_view::render(&self.metrics, frame, area, tick, ascii, &self.theme);
322            }
323            widgets::subagents::SubAgentSlotMode::Security => {
324                widgets::security::render(&self.metrics, frame, area, &self.theme);
325            }
326            widgets::subagents::SubAgentSlotMode::List => {
327                widgets::subagents::render(&self.metrics, frame, area, &self.theme);
328            }
329        }
330
331        // Overlay fleet panel over the subagents slot when `f` key is active (#3884).
332        if self.active_panel == Panel::Fleet {
333            widgets::fleet::render(
334                &self.fleet_snapshot,
335                frame,
336                area,
337                &mut self.fleet_list_state,
338                &self.theme,
339            );
340        }
341
342        // Overlay durable panel over the subagents slot when `D` key is active (spec-064, #4949).
343        if self.active_panel == Panel::Durable {
344            widgets::durable::render(
345                &self.durable_snapshot,
346                frame,
347                area,
348                &mut self.durable_list_state,
349                &self.theme,
350            );
351        }
352
353        // Overlay the read-only settings view over the subagents slot when `S` is
354        // active (issue #6024), mirroring the Fleet/Durable overlay precedent.
355        if self.active_panel == Panel::Settings {
356            widgets::settings::render(&self.metrics, &mut self.settings, frame, area, &self.theme);
357        }
358
359        // Overlay task registry over the subagents slot when `/tasks` is toggled.
360        if self.show_task_panel {
361            if self.task_supervisor.is_some() {
362                widgets::task_registry::render(
363                    &self.cached_task_snapshots,
364                    tick,
365                    area,
366                    frame,
367                    &self.theme,
368                    ascii,
369                );
370            } else {
371                let theme = &self.theme;
372                let header = Line::from(vec![
373                    Span::styled("≈ ", theme.highlight),
374                    Span::styled("tasks  supervisor not available", theme.system_message),
375                ]);
376                frame.render_widget(Clear, area);
377                frame.render_widget(Paragraph::new(header), area);
378            }
379        }
380    }
381
382    /// Render a single-row collapsed summary bar for the given panel label.
383    ///
384    /// When `focused` is true the brand glyph prefix and accent color replace the muted style.
385    fn render_collapsed_summary(
386        &self,
387        frame: &mut ratatui::Frame,
388        area: ratatui::layout::Rect,
389        label: &str,
390        focused: bool,
391    ) {
392        use ratatui::text::{Line, Span};
393        use ratatui::widgets::Paragraph;
394
395        if area.height == 0 || area.width == 0 {
396            return;
397        }
398        let line = if focused {
399            Line::from(vec![
400                Span::styled("≈ ", self.theme.highlight),
401                Span::styled(label, self.theme.highlight),
402            ])
403        } else {
404            Line::from(vec![
405                Span::styled("▸ ", self.theme.panel_border),
406                Span::styled(label, self.theme.panel_title),
407            ])
408        };
409        frame.render_widget(Paragraph::new(line), area);
410    }
411
412    /// Render a single-row focused section header (brand glyph + accent color).
413    fn render_section_header(
414        &self,
415        frame: &mut ratatui::Frame,
416        area: ratatui::layout::Rect,
417        label: &str,
418    ) {
419        use ratatui::text::{Line, Span};
420        use ratatui::widgets::Paragraph;
421
422        if area.height == 0 || area.width == 0 {
423            return;
424        }
425        let line = Line::from(vec![
426            Span::styled("≈ ", self.theme.highlight),
427            Span::styled(label, self.theme.highlight),
428        ]);
429        frame.render_widget(Paragraph::new(line), area);
430    }
431}
432
433/// Whether the equalizer slot should be carved from the bottom of the granted subagents
434/// rect this frame.
435///
436/// Matches `App::panel_demands()`'s own accounting: when the equalizer is active, the
437/// subagents demand already includes `+ EQ_PANEL_H` on top of its content rows, so a
438/// fully-granted slot has height `>= content_rows + EQ_PANEL_H` (`content_rows >= 1` for
439/// every base-layer mode). Requiring only `granted_height > EQ_PANEL_H` — the same
440/// floor-of-1 guarantee `fit_panel_heights` upholds elsewhere — carves the slot exactly when
441/// it was budgeted for (#6675 C1: the old `> EQ_PANEL_H + 2` margin predated content-driven
442/// sizing and silently ate the equalizer whenever the granted height matched a small content
443/// demand exactly, e.g. an empty sub-agent list).
444fn should_carve_equalizer(show_equalizer: bool, wave_active: bool, granted_height: u16) -> bool {
445    show_equalizer && wave_active && granted_height > EQ_PANEL_H
446}
447
448/// Return `area` with the top `n` rows removed.
449fn shrink_top(area: ratatui::layout::Rect, n: u16) -> ratatui::layout::Rect {
450    if n >= area.height {
451        return ratatui::layout::Rect {
452            x: area.x,
453            y: area.y + area.height,
454            width: area.width,
455            height: 0,
456        };
457    }
458    ratatui::layout::Rect {
459        x: area.x,
460        y: area.y + n,
461        width: area.width,
462        height: area.height - n,
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use ratatui::Terminal;
469    use ratatui::backend::TestBackend;
470    use tokio::sync::mpsc;
471
472    use super::{App, EQ_PANEL_H, should_carve_equalizer};
473
474    fn make_app() -> App {
475        let (user_tx, _) = mpsc::channel(1);
476        let (_, agent_rx) = mpsc::channel(1);
477        App::new(user_tx, agent_rx)
478    }
479
480    /// Fills the whole area with a sentinel glyph before calling `render_subagents_slot`, in
481    /// the same frame — mirroring the real bug shape (#6061): the task-panel
482    /// supervisor-unavailable fallback shares its `Rect` with Fleet/Durable/task-registry
483    /// overlays but, before the fix, never called `Clear`, so stale glyphs from whatever
484    /// rendered underneath survived in every cell the fallback `Paragraph` didn't touch.
485    fn render_fallback_over_sentinel(app: &mut App) -> ratatui::buffer::Buffer {
486        let backend = TestBackend::new(80, 10);
487        let mut terminal = Terminal::new(backend).unwrap();
488        terminal
489            .draw(|frame| {
490                let area = frame.area();
491                for y in area.top()..area.bottom() {
492                    for x in area.left()..area.right() {
493                        frame.buffer_mut()[(x, y)].set_symbol("#");
494                    }
495                }
496                app.render_subagents_slot(frame, area, 0, false);
497            })
498            .unwrap();
499        terminal.backend().buffer().clone()
500    }
501
502    #[test]
503    fn task_panel_fallback_clears_stale_glyphs_when_supervisor_unavailable() {
504        let mut app = make_app();
505        app.show_task_panel = true;
506        assert!(app.task_supervisor.is_none());
507
508        let buf = render_fallback_over_sentinel(&mut app);
509
510        for cell in &buf.content {
511            assert_ne!(
512                cell.symbol(),
513                "#",
514                "stray sentinel glyph survived render — Clear is missing or not applied \
515                 to the whole area"
516            );
517        }
518    }
519
520    // ── should_carve_equalizer / draw() regression (#6675 C1) ───────────────────
521
522    #[test]
523    fn should_carve_equalizer_true_when_granted_exactly_matches_budget() {
524        // An empty sub-agent list's List-mode content is 2 rows; with the equalizer active
525        // its demand is 2 + EQ_PANEL_H, and a fully-granted slot has exactly that height.
526        // The carve must still happen here — this used to require `> EQ_PANEL_H + 2`, which
527        // silently dropped the equalizer whenever granted height matched a small content
528        // demand exactly.
529        assert!(should_carve_equalizer(true, true, 2 + EQ_PANEL_H));
530    }
531
532    #[test]
533    fn should_carve_equalizer_false_when_no_room_for_any_content() {
534        assert!(!should_carve_equalizer(true, true, EQ_PANEL_H));
535    }
536
537    #[test]
538    fn should_carve_equalizer_false_when_hidden_or_idle() {
539        assert!(!should_carve_equalizer(false, true, 20));
540        assert!(!should_carve_equalizer(true, false, 20));
541    }
542
543    #[test]
544    fn draw_side_panel_rects_tile_the_column_without_overlap_under_varying_content() {
545        // #6675 tester gap 4: with genuinely different content sizes per panel (so
546        // `panel_demands()` produces different Rows(n) for each slot, unlike the old
547        // uniform Fill(1) split), the four granted rects must still stack contiguously
548        // inside `side_panel` — no gaps, no overlap, no rect exceeding the terminal.
549        let mut app = make_app();
550        app.metrics.active_skills = vec!["one".into(), "two".into()];
551        app.metrics.total_skills = 2;
552        app.metrics.sqlite_message_count = 10;
553        app.metrics.provider_name = "claude".into();
554        app.metrics.model_name = "opus-4".into();
555        app.metrics.total_tokens = 1000;
556
557        let backend = TestBackend::new(120, 40);
558        let mut terminal = Terminal::new(backend).unwrap();
559        terminal.draw(|frame| app.draw(frame)).unwrap();
560        let layout = app.last_layout.expect("draw() must populate last_layout");
561
562        let panels = [
563            layout.skills,
564            layout.memory,
565            layout.resources,
566            layout.subagents,
567        ];
568        for rect in panels {
569            assert!(
570                rect.y + rect.height <= layout.side_panel.y + layout.side_panel.height,
571                "panel rect {rect:?} must not exceed the side_panel column {:?}",
572                layout.side_panel
573            );
574            assert_eq!(
575                rect.x, layout.side_panel.x,
576                "panel rect must align with the side_panel column's left edge"
577            );
578            assert_eq!(rect.width, layout.side_panel.width);
579        }
580        // Contiguous, non-overlapping stacking: each slot starts exactly where the
581        // previous one ends.
582        assert_eq!(layout.skills.y, layout.side_panel.y);
583        assert_eq!(layout.memory.y, layout.skills.y + layout.skills.height);
584        assert_eq!(layout.resources.y, layout.memory.y + layout.memory.height);
585        assert_eq!(
586            layout.subagents.y,
587            layout.resources.y + layout.resources.height
588        );
589    }
590
591    #[test]
592    fn draw_carves_equalizer_slot_for_empty_subagents_list_while_busy() {
593        // Full regression for #6675 C1: with zero sub-agents and the agent busy, the
594        // equalizer used to never render because the old threshold required more headroom
595        // than an empty list's content-driven demand actually grants.
596        let mut app = make_app();
597        app.show_equalizer = true;
598        app.sessions.current_mut().status_label = Some("thinking...".to_owned());
599        let backend = TestBackend::new(120, 40);
600        let mut terminal = Terminal::new(backend).unwrap();
601        terminal.draw(|frame| app.draw(frame)).unwrap();
602        let layout = app.last_layout.expect("draw() must populate last_layout");
603        assert!(
604            layout.subagents.height < 2 + EQ_PANEL_H,
605            "equalizer must be carved out of the subagents slot for an empty, busy session, \
606             got subagents height={}",
607            layout.subagents.height
608        );
609    }
610}