1mod 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
20pub(crate) const PANEL_TITLE_PAD: u16 = 1;
23
24pub(crate) fn panel_block<'a>() -> Block<'a> {
27 Block::default()
28 .borders(Borders::ALL)
29 .border_type(BorderType::Rounded)
30}
31
32pub(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
46pub(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
59pub(crate) const MAX_AGENT_PANEL_ROWS: usize = 4;
62const MAX_AGENT_HEIGHT: u16 = 10;
67const 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 pub(crate) queue: Rect,
77 pub(crate) status: Rect,
78 pub(crate) goal: Rect,
81 pub(crate) input: Rect,
82 pub(crate) bar: Rect,
83}
84
85pub(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 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}