Skip to main content

cookie_agent_tui/ui/
mod.rs

1//! Ratatui layout composition and UI module wiring.
2
3mod app;
4mod events;
5mod input;
6mod management;
7mod pickers;
8mod provider;
9mod slash;
10mod transcript;
11
12pub use app::{App, run_with_client, run_with_new_session};
13
14use ratatui::{
15    layout::Rect,
16    text::{Line, Span},
17    widgets::{Block, BorderType, Borders},
18};
19
20/// Blank columns a panel title keeps from the border on each side, so it
21/// never butts against the corner: `╭ Conversation ───╮`.
22pub(crate) const PANEL_TITLE_PAD: u16 = 1;
23
24/// Every bordered panel, dialog, and button: rounded corners, echoing the
25/// transcript's `╭─` message gutters.
26pub(crate) fn panel_block<'a>() -> Block<'a> {
27    Block::default()
28        .borders(Borders::ALL)
29        .border_type(BorderType::Rounded)
30}
31
32/// A border title with [`PANEL_TITLE_PAD`] blank columns either side. The pad
33/// spans carry no style of their own, so a highlighted title's color stops at
34/// its text. An empty title stays empty rather than leaving a gap.
35pub(crate) fn panel_title<'a>(title: impl Into<Line<'a>>) -> Line<'a> {
36    let mut title = title.into();
37    if title.width() == 0 {
38        return title;
39    }
40    let pad = " ".repeat(usize::from(PANEL_TITLE_PAD));
41    title.spans.insert(0, Span::raw(pad.clone()));
42    title.spans.push(Span::raw(pad));
43    title
44}
45
46/// [`panel_title`] for a title that may fill a border `area_width` columns
47/// wide. One too wide for its pads keeps none, so it sits flush at both ends
48/// instead of losing just the pad on the side the border clips.
49pub(crate) fn fitted_panel_title<'a>(title: impl Into<Line<'a>>, area_width: u16) -> Line<'a> {
50    let title = title.into();
51    let room = usize::from(area_width.saturating_sub(2));
52    if title.width() + 2 * usize::from(PANEL_TITLE_PAD) > room {
53        title
54    } else {
55        panel_title(title)
56    }
57}
58
59/// The most Agents rows the panel ever shows at once; a longer tree scrolls
60/// within this viewport instead of growing the panel.
61pub(crate) const MAX_AGENT_PANEL_ROWS: usize = 4;
62/// Absolute ceiling for the whole Agents panel, borders included: it exists to
63/// keep a pathological layout from starving the conversation. The panel is
64/// already bounded by [`MAX_AGENT_PANEL_ROWS`] text rows plus two borders, so
65/// this stays deliberately looser than that (4 + 2 = 6 ≤ 10).
66const MAX_AGENT_HEIGHT: u16 = 10;
67/// Composer ceiling in total rows: five text rows plus top/bottom borders.
68const MAX_INPUT_HEIGHT: u16 = 7;
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub(crate) struct UiLayout {
72    pub(crate) agent: Rect,
73    pub(crate) conversation: Rect,
74    /// The steer-queue strip between the conversation pane and the status
75    /// line; zero height while the selected session's queue is empty.
76    pub(crate) queue: Rect,
77    pub(crate) status: Rect,
78    /// The persistent goal bar directly above the composer; zero height when
79    /// it is not visible or the terminal has no space for it.
80    pub(crate) goal: Rect,
81    pub(crate) input: Rect,
82    pub(crate) bar: Rect,
83}
84
85/// Resolve the single top-to-bottom pane geometry for a known visible tree
86/// row count, queue-strip row demand, goal visibility, and composer text-row
87/// demand. The Agents panel is hidden when `visible_tree_row_count` is zero or
88/// one; otherwise it is exactly `clamp(visible_tree_row_count, 1,
89/// MAX_AGENT_PANEL_ROWS)` text rows — a longer tree scrolls inside that
90/// viewport instead of growing the panel — with its borders outside that count,
91/// so the conversation starts immediately below it. The composer takes one text
92/// row by default and grows with its content up to five; every added composer
93/// row is reclaimed from the conversation pane. The goal bar takes exactly one
94/// row when it is visible and space remains above the composer, with the
95/// ephemeral status line yielding first on short terminals. The queue strip
96/// reclaims conversation rows the same way and leaves the conversation at least
97/// one row whenever content space remains after fixed bars.
98pub(crate) fn terminal_layout_with_tree_rows(
99    area: Rect,
100    visible_tree_rows: usize,
101    queue_rows: u16,
102    goal_visible: bool,
103    input_text_rows: u16,
104) -> UiLayout {
105    let agent_height = if visible_tree_rows <= 1 {
106        0
107    } else {
108        (visible_tree_rows.clamp(1, MAX_AGENT_PANEL_ROWS) as u16)
109            .saturating_add(2)
110            .min(MAX_AGENT_HEIGHT)
111    };
112    let bar_height = u16::from(area.height > 0);
113    let input_height = input_text_rows
114        .clamp(1, input::MAX_TEXT_ROWS)
115        .saturating_add(2)
116        .min(MAX_INPUT_HEIGHT)
117        .min(area.height.saturating_sub(bar_height));
118    let available_height = area
119        .height
120        .saturating_sub(input_height)
121        .saturating_sub(bar_height);
122    let goal_height = u16::from(goal_visible && available_height > 0);
123    let status_height = u16::from(available_height.saturating_sub(goal_height) >= 3);
124    let content_height = available_height
125        .saturating_sub(goal_height)
126        .saturating_sub(status_height);
127    let agent_height = if content_height > 1 {
128        agent_height.min(content_height - 1)
129    } else {
130        content_height
131    };
132    let conversation_available = content_height.saturating_sub(agent_height);
133    // The strip never starves the conversation completely: on a cramped
134    // terminal it shrinks away rather than taking the last row.
135    let queue_height = queue_rows.min(conversation_available.saturating_sub(1));
136    let conversation_height = conversation_available.saturating_sub(queue_height);
137    let agent = Rect::new(area.x, area.y, area.width, agent_height);
138    let conversation = Rect::new(
139        area.x,
140        area.y.saturating_add(agent_height),
141        area.width,
142        conversation_height,
143    );
144    let queue = Rect::new(
145        area.x,
146        conversation.y.saturating_add(conversation_height),
147        area.width,
148        queue_height,
149    );
150    let status = Rect::new(
151        area.x,
152        queue.y.saturating_add(queue_height),
153        area.width,
154        status_height,
155    );
156    let goal = Rect::new(
157        area.x,
158        status.y.saturating_add(status_height),
159        area.width,
160        goal_height,
161    );
162    let input = Rect::new(
163        area.x,
164        area.y
165            .saturating_add(area.height.saturating_sub(bar_height + input_height)),
166        area.width,
167        input_height,
168    );
169    let bar = Rect::new(
170        area.x,
171        area.y
172            .saturating_add(area.height.saturating_sub(bar_height)),
173        area.width,
174        bar_height,
175    );
176    UiLayout {
177        agent,
178        conversation,
179        queue,
180        status,
181        goal,
182        input,
183        bar,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::terminal_layout_with_tree_rows;
190    use ratatui::layout::Rect;
191
192    #[test]
193    fn hidden_goal_preserves_existing_geometry_without_a_blank_row() {
194        for width in [1, 3, 7, 8, 20, 40, 80] {
195            for height in 0..=24 {
196                let area = Rect::new(2, 3, width, height);
197                let layout = terminal_layout_with_tree_rows(area, 3, 5, false, 2);
198
199                assert_eq!(layout.goal.height, 0, "{width}x{height}");
200                assert_eq!(layout.goal.y, layout.input.y, "{width}x{height}");
201                assert_eq!(
202                    layout.status.y + layout.status.height,
203                    layout.input.y,
204                    "{width}x{height}"
205                );
206            }
207        }
208    }
209
210    #[test]
211    fn visible_goal_is_one_row_when_space_exists_and_never_overlaps() {
212        for width in [1, 3, 7, 8, 20, 40, 80] {
213            for height in 0..=24 {
214                let area = Rect::new(2, 3, width, height);
215                let hidden = terminal_layout_with_tree_rows(area, 3, 5, false, 1);
216                let shown = terminal_layout_with_tree_rows(area, 3, 5, true, 1);
217                let expected_goal_height = u16::from(
218                    area.height
219                        .saturating_sub(hidden.input.height)
220                        .saturating_sub(hidden.bar.height)
221                        > 0,
222                );
223
224                assert_eq!(shown.goal.height, expected_goal_height, "{width}x{height}");
225                assert_eq!(shown.goal.y + shown.goal.height, shown.input.y);
226                assert_eq!(shown.input, hidden.input, "{width}x{height}");
227                assert_eq!(shown.bar, hidden.bar, "{width}x{height}");
228
229                let panes = [
230                    shown.agent,
231                    shown.conversation,
232                    shown.queue,
233                    shown.status,
234                    shown.goal,
235                    shown.input,
236                    shown.bar,
237                ];
238                for pane in panes {
239                    assert_eq!(pane.x, area.x, "{width}x{height}: {pane:?}");
240                    assert_eq!(pane.width, area.width, "{width}x{height}: {pane:?}");
241                    assert!(
242                        pane.y >= area.y && pane.y + pane.height <= area.y + area.height,
243                        "{width}x{height}: {pane:?} outside {area:?}"
244                    );
245                }
246                for pair in panes.windows(2) {
247                    assert!(
248                        pair[0].y + pair[0].height <= pair[1].y,
249                        "{width}x{height}: {:?} overlaps {:?}",
250                        pair[0],
251                        pair[1]
252                    );
253                }
254            }
255        }
256    }
257
258    #[test]
259    fn visible_goal_is_reserved_before_ephemeral_status() {
260        let area = Rect::new(0, 0, 20, 7);
261        let hidden = terminal_layout_with_tree_rows(area, 0, 0, false, 1);
262        let shown = terminal_layout_with_tree_rows(area, 0, 0, true, 1);
263
264        assert_eq!(hidden.status.height, 1);
265        assert_eq!(hidden.goal.height, 0);
266        assert_eq!(shown.status.height, 0);
267        assert_eq!(shown.goal.height, 1);
268        assert_eq!(shown.goal.y + shown.goal.height, shown.input.y);
269        assert_eq!(shown.input, hidden.input);
270        assert_eq!(shown.bar, hidden.bar);
271    }
272}