1use 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 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 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 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 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 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 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 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 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 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 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 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 if self.active_panel == Panel::Settings {
356 widgets::settings::render(&self.metrics, &mut self.settings, frame, area, &self.theme);
357 }
358
359 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 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 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
433fn 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
448fn 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 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 #[test]
523 fn should_carve_equalizer_true_when_granted_exactly_matches_budget() {
524 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 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 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 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}