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