Skip to main content

oxicode_vtui/design/layout/
agent.rs

1//! Agent view layout — pure geometry computation.
2//!
3//! Ported from grok-build's `views/agent.rs` (`AgentViewLayout`,
4//! `ActivePane`, `PaneAreas`).  The layout is computed from screen area +
5//! appearance config + per-pane heights, producing a set of [`Rect`]s that
6//! widgets render into.
7//!
8//! ## Vertical stack (top → bottom)
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────┐
12//! │ [Startup warnings]                optional  │
13//! │ [Tasks pane]                      optional  │
14//! │ [Catalog pane]                    optional  │
15//! │ [Todo pane]                       optional  │
16//! ├─────────────────────────────────────────────┤
17//! │ Scrollback               Min(5) — dominant  │
18//! ├─────────────────────────────────────────────┤
19//! │ [BTW panel]                       optional  │
20//! │ [Queue pane]                      optional  │
21//! │ [Turn status]                     optional  │
22//! │ [Banner / CTA / Follow-ups]       optional  │
23//! ├─────────────────────────────────────────────┤
24//! │ Prompt                fixed height          │
25//! ├─────────────────────────────────────────────┤
26//! │ ShortcutsBar            1 row               │
27//! └─────────────────────────────────────────────┘
28//! ```
29
30use ratatui::layout::{Constraint, Layout, Rect};
31use ratatui::widgets::{Block, Padding};
32
33use super::config::{LayoutConfig, ScrollbarConfig};
34
35// ───────────────────────────────────────────────────────────────────────────
36// ActivePane
37// ───────────────────────────────────────────────────────────────────────────
38
39/// Which pane is currently active (has keyboard focus) in the agent view.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ActivePane {
42    /// Main conversation scrollback (default).
43    #[default]
44    Scrollback,
45    /// Todo checklist side-pane.
46    Todo,
47    /// Prompt queue side-pane.
48    Queue,
49    /// Text input prompt.
50    Prompt,
51    /// Background tasks pane.
52    Tasks,
53    /// Subagent / extension catalog pane.
54    Catalog,
55}
56
57impl ActivePane {
58    /// Cycle to the next visible pane.  `visible` is the set of panes
59    /// that currently have non-zero height (from [`PaneAreas`]).
60    #[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// ───────────────────────────────────────────────────────────────────────────
82// PaneAreas (mouse hit-testing)
83// ───────────────────────────────────────────────────────────────────────────
84
85/// Cached pane rectangles from the last render, used for mouse hit-testing.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct PaneAreas {
88    /// Scrollback conversation area.
89    pub scrollback: Rect,
90    /// Todo side-pane area.
91    pub todo: Rect,
92    /// Queue side-pane area.
93    pub queue: Rect,
94    /// Prompt input area.
95    pub prompt: Rect,
96    /// Background tasks pane area.
97    pub tasks: Rect,
98    /// Subagent / extension catalog area.
99    pub catalog: Rect,
100}
101
102impl PaneAreas {
103    /// Determine which pane a screen position falls in, if any.
104    #[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    /// Whether a pane is currently visible (non-zero area).
129    #[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
142// ───────────────────────────────────────────────────────────────────────────
143// Constants
144// ───────────────────────────────────────────────────────────────────────────
145
146/// Terminals at or below this height suppress optional rows above the prompt.
147pub const SHORT_TERMINAL_ROWS: u16 = 16;
148
149/// Auto-compact threshold.
150pub const AUTO_COMPACT_MAX_ROWS: u16 = 20;
151
152const _: () = assert!(SHORT_TERMINAL_ROWS < AUTO_COMPACT_MAX_ROWS);
153
154/// Render-value derivation for compact mode.
155#[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// ───────────────────────────────────────────────────────────────────────────
161// AgentViewLayout
162// ───────────────────────────────────────────────────────────────────────────
163
164/// Computed screen layout for the agent view.
165///
166/// Pure data — no rendering.  Computed from screen area + appearance config +
167/// per-pane heights via [`compute`](Self::compute).
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
169pub struct AgentViewLayout {
170    /// Startup warning banner.
171    pub startup_warnings: Rect,
172    /// Background tasks pane.
173    pub tasks: Rect,
174    /// Subagent / extension catalog pane.
175    pub catalog: Rect,
176    /// Main conversation scrollback (dominant).
177    pub scrollback: Rect,
178    /// Todo checklist side-pane.
179    pub todo: Rect,
180    /// Prompt queue side-pane.
181    pub queue: Rect,
182    /// Inline side-question panel.
183    pub btw: Rect,
184    /// Turn status line.
185    pub turn_status: Rect,
186    /// Banner row above the prompt.
187    pub banner: Rect,
188    /// Inline CTA row.
189    pub plugin_cta: Rect,
190    /// Follow-up suggestion chips row.
191    pub follow_ups: Rect,
192    /// Voice recording indicator row.
193    pub voice_recording: Rect,
194    /// Prompt input widget.
195    pub prompt: Rect,
196    /// Bottom shortcuts bar.
197    pub shortcuts: Rect,
198    /// Scrollback area narrowed for scrollbar.
199    pub scrollback_content: Rect,
200    /// Scrollbar track x-coordinate.
201    pub scrollbar_x: u16,
202    /// Timeline rail left edge (0 = hidden).
203    pub timeline_x: u16,
204    /// Columns reserved for the timeline rail (0 = hidden).
205    pub timeline_width: u16,
206}
207
208/// Inputs to [`AgentViewLayout::compute`], bundled so the call site names
209/// each field — preventing accidental transposition of the many `u16`
210/// height parameters.
211#[derive(Debug, Clone, Copy, Default)]
212pub struct LayoutInput {
213    /// Prompt widget height (rows).
214    pub prompt_height: u16,
215    /// Background tasks pane height (0 = hidden).
216    pub tasks_height: u16,
217    /// Catalog pane height (0 = hidden).
218    pub catalog_height: u16,
219    /// Todo pane height (0 = hidden).
220    pub todo_height: u16,
221    /// Queue pane height (0 = hidden).
222    pub queue_height: u16,
223    /// BTW side-question panel height (0 = hidden).
224    pub btw_height: u16,
225    /// Turn status height (0 = hidden).
226    pub turn_status_height: u16,
227    /// Banner height (0 = hidden).
228    pub banner_height: u16,
229    /// Plugin CTA height (0 = hidden).
230    pub cta_height: u16,
231    /// Follow-up chips height (0 = hidden).
232    pub follow_ups_height: u16,
233    /// Startup warning height (0 = hidden).
234    pub startup_warning_height: u16,
235    /// Gap row between turn-status/scrollback and the prompt (0 or 1).
236    pub prompt_gap: u16,
237    /// Voice recording indicator height (0 = hidden).
238    pub voice_recording_height: u16,
239    /// Shortcuts bar height (always ≥ 1).
240    pub shortcuts_height: u16,
241    /// Timeline rail width (0 = hidden; requires scrollbar enabled).
242    pub timeline_width: u16,
243    /// Compact mode flag (affects padding).
244    pub compact: bool,
245}
246
247impl AgentViewLayout {
248    /// Compute layout from screen area, appearance config, and per-pane heights.
249    ///
250    /// When any optional pane height is `0`, both the pane and its separator
251    /// gap are omitted from the constraint list.
252    #[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)); // Scrollback — dominant
309
310        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; // gap between the top panes and the scrollback
365        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    /// Inner area width (for prompt height computation before full layout).
436    #[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    /// Convert to [`PaneAreas`] for mouse hit-testing.
449    #[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        // No dedicated chrome row above the scrollback: it starts directly
515        // under the outer vpad plus the pane/scrollback separator gap.
516        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}