1use ratatui::layout::{Constraint, Layout, Rect};
31use ratatui::widgets::{Block, Padding};
32
33use super::config::{LayoutConfig, ScrollbarConfig};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ActivePane {
42 #[default]
44 Scrollback,
45 Todo,
47 Queue,
49 Prompt,
51 Tasks,
53 Catalog,
55}
56
57impl ActivePane {
58 #[must_use]
61 pub fn cycle(self, visible: &PaneAreas) -> Self {
62 let order = [
63 ActivePane::Scrollback,
64 ActivePane::Todo,
65 ActivePane::Queue,
66 ActivePane::Tasks,
67 ActivePane::Catalog,
68 ActivePane::Prompt,
69 ];
70 let start = order.iter().position(|&p| p == self).unwrap_or(0);
71 for i in 1..=order.len() {
72 let candidate = order[(start + i) % order.len()];
73 if visible.is_visible(candidate) {
74 return candidate;
75 }
76 }
77 self
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct PaneAreas {
88 pub scrollback: Rect,
90 pub todo: Rect,
92 pub queue: Rect,
94 pub prompt: Rect,
96 pub tasks: Rect,
98 pub catalog: Rect,
100}
101
102impl PaneAreas {
103 #[must_use]
105 pub fn hit_test(&self, col: u16, row: u16) -> Option<ActivePane> {
106 let pos = (col, row).into();
107 if self.tasks.area() > 0 && self.tasks.contains(pos) {
108 return Some(ActivePane::Tasks);
109 }
110 if self.catalog.area() > 0 && self.catalog.contains(pos) {
111 return Some(ActivePane::Catalog);
112 }
113 if self.todo.area() > 0 && self.todo.contains(pos) {
114 return Some(ActivePane::Todo);
115 }
116 if self.queue.area() > 0 && self.queue.contains(pos) {
117 return Some(ActivePane::Queue);
118 }
119 if self.scrollback.area() > 0 && self.scrollback.contains(pos) {
120 return Some(ActivePane::Scrollback);
121 }
122 if self.prompt.area() > 0 && self.prompt.contains(pos) {
123 return Some(ActivePane::Prompt);
124 }
125 None
126 }
127
128 #[must_use]
130 pub fn is_visible(&self, pane: ActivePane) -> bool {
131 match pane {
132 ActivePane::Scrollback => self.scrollback.area() > 0,
133 ActivePane::Todo => self.todo.area() > 0,
134 ActivePane::Queue => self.queue.area() > 0,
135 ActivePane::Prompt => self.prompt.area() > 0,
136 ActivePane::Tasks => self.tasks.area() > 0,
137 ActivePane::Catalog => self.catalog.area() > 0,
138 }
139 }
140}
141
142pub const SHORT_TERMINAL_ROWS: u16 = 16;
148
149pub const AUTO_COMPACT_MAX_ROWS: u16 = 20;
151
152const _: () = assert!(SHORT_TERMINAL_ROWS < AUTO_COMPACT_MAX_ROWS);
153
154#[must_use]
156pub fn effective_compact(user_compact: bool, terminal_rows: u16) -> bool {
157 user_compact || (terminal_rows > 0 && terminal_rows <= AUTO_COMPACT_MAX_ROWS)
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
169pub struct AgentViewLayout {
170 pub startup_warnings: Rect,
172 pub tasks: Rect,
174 pub catalog: Rect,
176 pub scrollback: Rect,
178 pub todo: Rect,
180 pub queue: Rect,
182 pub btw: Rect,
184 pub turn_status: Rect,
186 pub banner: Rect,
188 pub plugin_cta: Rect,
190 pub follow_ups: Rect,
192 pub voice_recording: Rect,
194 pub prompt: Rect,
196 pub shortcuts: Rect,
198 pub scrollback_content: Rect,
200 pub scrollbar_x: u16,
202 pub timeline_x: u16,
204 pub timeline_width: u16,
206}
207
208#[derive(Debug, Clone, Copy, Default)]
212pub struct LayoutInput {
213 pub prompt_height: u16,
215 pub tasks_height: u16,
217 pub catalog_height: u16,
219 pub todo_height: u16,
221 pub queue_height: u16,
223 pub btw_height: u16,
225 pub turn_status_height: u16,
227 pub banner_height: u16,
229 pub cta_height: u16,
231 pub follow_ups_height: u16,
233 pub startup_warning_height: u16,
235 pub prompt_gap: u16,
237 pub voice_recording_height: u16,
239 pub shortcuts_height: u16,
241 pub timeline_width: u16,
243 pub compact: bool,
245}
246
247impl AgentViewLayout {
248 #[must_use]
253 pub fn compute(
254 area: Rect,
255 layout_cfg: &LayoutConfig,
256 scrollbar_cfg: &ScrollbarConfig,
257 input: LayoutInput,
258 ) -> Self {
259 let compact = input.compact;
260 let outer_vpad = layout_cfg.eff_outer_vpad(compact);
261 let bottom_vpad = if area.height <= SHORT_TERMINAL_ROWS {
262 0
263 } else {
264 outer_vpad
265 };
266 let cta_height = if area.height <= SHORT_TERMINAL_ROWS {
267 0
268 } else {
269 input.cta_height
270 };
271 let follow_ups_height = if area.height <= SHORT_TERMINAL_ROWS {
272 0
273 } else {
274 input.follow_ups_height
275 };
276
277 let top_vpad = outer_vpad;
278 let outer_block = Block::default().padding(Padding::new(
279 layout_cfg.eff_hpad_left(compact),
280 layout_cfg.eff_hpad_right(compact),
281 top_vpad,
282 bottom_vpad,
283 ));
284 let inner_area = outer_block.inner(area);
285
286 let mut constraints: Vec<Constraint> = Vec::new();
287
288 if input.startup_warning_height > 0 {
289 constraints.push(Constraint::Length(input.startup_warning_height));
290 }
291
292 let pane_gap: u16 = if top_vpad == 0 { 0 } else { 1 };
293 if input.tasks_height > 0 {
294 constraints.push(Constraint::Length(pane_gap));
295 constraints.push(Constraint::Length(input.tasks_height));
296 }
297 if input.catalog_height > 0 {
298 constraints.push(Constraint::Length(pane_gap));
299 constraints.push(Constraint::Length(input.catalog_height));
300 }
301 if input.todo_height > 0 {
302 constraints.push(Constraint::Length(pane_gap));
303 constraints.push(Constraint::Length(input.todo_height));
304 }
305
306 let status_gap: u16 = if top_vpad == 0 { 0 } else { 1 };
307 constraints.push(Constraint::Length(status_gap));
308 constraints.push(Constraint::Min(5)); if input.btw_height > 0 {
311 constraints.push(Constraint::Length(1));
312 constraints.push(Constraint::Length(input.btw_height));
313 }
314 if input.queue_height > 0 {
315 constraints.push(Constraint::Length(1));
316 constraints.push(Constraint::Length(input.queue_height));
317 }
318 if input.turn_status_height > 0 {
319 constraints.push(Constraint::Length(1));
320 constraints.push(Constraint::Length(input.turn_status_height));
321 }
322 if input.banner_height > 0 {
323 constraints.push(Constraint::Length(1));
324 constraints.push(Constraint::Length(input.banner_height));
325 }
326 if cta_height > 0 {
327 constraints.push(Constraint::Length(1));
328 constraints.push(Constraint::Length(cta_height));
329 }
330 if follow_ups_height > 0 {
331 constraints.push(Constraint::Length(1));
332 constraints.push(Constraint::Length(follow_ups_height));
333 }
334 if input.prompt_gap > 0 {
335 constraints.push(Constraint::Length(input.prompt_gap));
336 }
337 if input.voice_recording_height > 0 {
338 constraints.push(Constraint::Length(input.voice_recording_height));
339 }
340 constraints.push(Constraint::Length(input.prompt_height));
341
342 let shortcuts_gap: u16 = if input.shortcuts_height == 0 || bottom_vpad == 0 {
343 0
344 } else {
345 1
346 };
347 if shortcuts_gap > 0 {
348 constraints.push(Constraint::Length(shortcuts_gap));
349 }
350 if input.shortcuts_height > 0 {
351 constraints.push(Constraint::Length(input.shortcuts_height));
352 }
353
354 let chunks = Layout::vertical(constraints).split(inner_area);
355
356 let mut i = 0;
357
358 let startup_warnings =
359 Self::take_optional(&chunks, &mut i, input.startup_warning_height > 0);
360 let tasks = Self::take_pane(&chunks, &mut i, input.tasks_height > 0);
361 let catalog = Self::take_pane(&chunks, &mut i, input.catalog_height > 0);
362 let todo = Self::take_pane(&chunks, &mut i, input.todo_height > 0);
363
364 i += 1; let scrollback = chunks[i];
366 i += 1;
367
368 let btw = Self::take_section(&chunks, &mut i, input.btw_height > 0);
369 let queue = Self::take_section(&chunks, &mut i, input.queue_height > 0);
370 let turn_status = Self::take_section(&chunks, &mut i, input.turn_status_height > 0);
371 let banner = Self::take_section(&chunks, &mut i, input.banner_height > 0);
372 let plugin_cta = Self::take_section(&chunks, &mut i, cta_height > 0);
373 let follow_ups = Self::take_section(&chunks, &mut i, follow_ups_height > 0);
374
375 if input.prompt_gap > 0 {
376 i += 1;
377 }
378 let voice_recording =
379 Self::take_optional(&chunks, &mut i, input.voice_recording_height > 0);
380 let prompt = chunks[i];
381 i += 1;
382
383 let shortcuts = if input.shortcuts_height > 0 {
384 if shortcuts_gap > 0 {
385 i += 1;
386 }
387 chunks[i]
388 } else {
389 Rect::ZERO
390 };
391 let scrollbar_x = area.right().saturating_sub(scrollbar_cfg.gap_right + 1);
392 let timeline_width = if scrollbar_cfg.enabled {
393 input.timeline_width
394 } else {
395 0
396 };
397 let timeline_x = (scrollbar_x + 1).saturating_sub(timeline_width);
398 let content_end_x = if timeline_width > 0 {
399 timeline_x.saturating_sub(scrollbar_cfg.gap_left)
400 } else {
401 scrollbar_x.saturating_sub(scrollbar_cfg.gap_left)
402 };
403 let scrollback_right = scrollback.x + scrollback.width;
404 let scrollback_content = if !scrollbar_cfg.enabled || content_end_x >= scrollback_right {
405 scrollback
406 } else {
407 Rect {
408 width: content_end_x.saturating_sub(scrollback.x),
409 ..scrollback
410 }
411 };
412
413 Self {
414 startup_warnings,
415 tasks,
416 catalog,
417 scrollback,
418 todo,
419 queue,
420 btw,
421 turn_status,
422 banner,
423 plugin_cta,
424 follow_ups,
425 voice_recording,
426 prompt,
427 shortcuts,
428 scrollback_content,
429 scrollbar_x,
430 timeline_x,
431 timeline_width,
432 }
433 }
434
435 #[must_use]
437 pub fn inner_width(area: Rect, layout_cfg: &LayoutConfig, compact: bool) -> u16 {
438 let vpad = layout_cfg.eff_outer_vpad(compact);
439 let outer_block = Block::default().padding(Padding::new(
440 layout_cfg.eff_hpad_left(compact),
441 layout_cfg.eff_hpad_right(compact),
442 vpad,
443 vpad,
444 ));
445 outer_block.inner(area).width
446 }
447
448 #[must_use]
450 pub fn pane_areas(&self) -> PaneAreas {
451 PaneAreas {
452 scrollback: self.scrollback,
453 todo: self.todo,
454 queue: self.queue,
455 prompt: self.prompt,
456 tasks: self.tasks,
457 catalog: self.catalog,
458 }
459 }
460
461 fn take_optional(chunks: &[Rect], i: &mut usize, present: bool) -> Rect {
462 if present {
463 let r = chunks[*i];
464 *i += 1;
465 r
466 } else {
467 Rect::default()
468 }
469 }
470
471 fn take_section(chunks: &[Rect], i: &mut usize, present: bool) -> Rect {
472 if present {
473 *i += 1;
474 let r = chunks[*i];
475 *i += 1;
476 r
477 } else {
478 Rect::default()
479 }
480 }
481
482 fn take_pane(chunks: &[Rect], i: &mut usize, present: bool) -> Rect {
483 if present {
484 *i += 1;
485 let r = chunks[*i];
486 *i += 1;
487 r
488 } else {
489 Rect::default()
490 }
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 fn screen(h: u16) -> Rect {
499 Rect::new(0, 0, 80, h)
500 }
501
502 #[test]
503 fn basic_layout_minimal() {
504 let layout = AgentViewLayout::compute(
505 screen(24),
506 &LayoutConfig::default(),
507 &ScrollbarConfig::default(),
508 LayoutInput {
509 prompt_height: 3,
510 shortcuts_height: 1,
511 ..Default::default()
512 },
513 );
514 let vpad = LayoutConfig::default().eff_outer_vpad(false);
517 assert_eq!(
518 layout.scrollback.y,
519 vpad + u16::from(vpad > 0),
520 "scrollback starts at the vpad (+gap), no status-bar row"
521 );
522 assert!(layout.scrollback.height >= 5);
523 assert!(layout.prompt.y < layout.shortcuts.y);
524 }
525
526 #[test]
527 fn all_panes_visible() {
528 let layout = AgentViewLayout::compute(
529 screen(60),
530 &LayoutConfig::default(),
531 &ScrollbarConfig::default(),
532 LayoutInput {
533 prompt_height: 3,
534 shortcuts_height: 1,
535 tasks_height: 5,
536 catalog_height: 4,
537 todo_height: 5,
538 queue_height: 4,
539 turn_status_height: 1,
540 banner_height: 1,
541 ..Default::default()
542 },
543 );
544 assert!(layout.tasks.height > 0);
545 assert!(layout.todo.height > 0);
546 assert!(layout.queue.height > 0);
547 assert!(layout.tasks.y < layout.scrollback.y);
548 assert!(layout.queue.y > layout.scrollback.y);
549 }
550
551 #[test]
552 fn optional_panes_collapse_to_zero() {
553 let layout = AgentViewLayout::compute(
554 screen(24),
555 &LayoutConfig::default(),
556 &ScrollbarConfig::default(),
557 LayoutInput {
558 prompt_height: 3,
559 shortcuts_height: 1,
560 ..Default::default()
561 },
562 );
563 assert_eq!(layout.tasks, Rect::default());
564 assert_eq!(layout.todo, Rect::default());
565 }
566
567 #[test]
568 fn short_terminal_suppresses_cta_and_followups() {
569 let layout = AgentViewLayout::compute(
570 screen(SHORT_TERMINAL_ROWS),
571 &LayoutConfig::default(),
572 &ScrollbarConfig::default(),
573 LayoutInput {
574 prompt_height: 3,
575 shortcuts_height: 1,
576 cta_height: 1,
577 follow_ups_height: 1,
578 ..Default::default()
579 },
580 );
581 assert_eq!(layout.plugin_cta, Rect::default());
582 assert_eq!(layout.follow_ups, Rect::default());
583 }
584
585 #[test]
586 fn pane_areas_hit_test() {
587 let layout = AgentViewLayout::compute(
588 screen(24),
589 &LayoutConfig::default(),
590 &ScrollbarConfig::default(),
591 LayoutInput {
592 prompt_height: 3,
593 shortcuts_height: 1,
594 todo_height: 5,
595 ..Default::default()
596 },
597 );
598 let areas = layout.pane_areas();
599 assert_eq!(
600 areas.hit_test(layout.scrollback.x, layout.scrollback.y),
601 Some(ActivePane::Scrollback)
602 );
603 assert_eq!(
604 areas.hit_test(layout.todo.x, layout.todo.y),
605 Some(ActivePane::Todo)
606 );
607 assert_eq!(
608 areas.hit_test(layout.prompt.x, layout.prompt.y),
609 Some(ActivePane::Prompt)
610 );
611 }
612
613 #[test]
614 fn active_pane_cycle() {
615 let areas = PaneAreas {
616 scrollback: Rect::new(0, 0, 10, 10),
617 prompt: Rect::new(0, 10, 10, 3),
618 ..Default::default()
619 };
620 assert_eq!(ActivePane::Scrollback.cycle(&areas), ActivePane::Prompt);
621 assert_eq!(ActivePane::Prompt.cycle(&areas), ActivePane::Scrollback);
622 }
623
624 #[test]
625 fn effective_compact_logic() {
626 assert!(!effective_compact(false, 0));
627 assert!(effective_compact(false, AUTO_COMPACT_MAX_ROWS));
628 assert!(effective_compact(false, 10));
629 assert!(!effective_compact(false, 30));
630 assert!(effective_compact(true, 100));
631 }
632
633 #[test]
634 fn inner_width_without_padding() {
635 let w =
636 AgentViewLayout::inner_width(Rect::new(0, 0, 80, 24), &LayoutConfig::default(), false);
637 assert_eq!(w, 76);
638 }
639}