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