1use 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 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 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 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 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 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 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 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 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 !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 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 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 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 if self.active_panel == Panel::Settings {
361 widgets::settings::render(&self.metrics, &mut self.settings, frame, area, &self.theme);
362 }
363
364 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 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 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
438fn 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 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}