1use super::*;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4enum BottomPanelKind {
5 None,
6 InlineModal,
7 FilePalette,
8 HistoryPicker,
9 SlashPalette,
10 TaskPanel,
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14struct BottomPanelSpec {
15 kind: BottomPanelKind,
16 height: u16,
17}
18
19impl Session {
20 pub fn render(&mut self, frame: &mut Frame<'_>) {
21 let viewport = frame.area();
22 if viewport.height == 0 || viewport.width == 0 {
23 return;
24 }
25
26 if self.needs_full_clear {
28 frame.render_widget(Clear, viewport);
29 self.needs_full_clear = false;
30 }
31
32 let header_lines = self.header_lines();
34 let header_height = self.header_height_from_lines(viewport.width, &header_lines);
35 if header_height != self.header_rows {
36 self.header_rows = header_height;
37 self.recalculate_transcript_rows();
38 }
39
40 let inner_width = viewport.width.saturating_sub(2);
41 let desired_lines = self.desired_input_lines(inner_width);
42 let block_height = Self::input_block_height_for_lines(desired_lines);
43 let status_height = ui::INLINE_INPUT_STATUS_HEIGHT;
44 let input_core_height = block_height.saturating_add(status_height);
45 let panel = resolve_bottom_panel_spec(self, viewport, header_height, input_core_height);
46 let input_height = input_core_height.saturating_add(panel.height);
47 self.apply_input_height(input_height);
48
49 let mut constraints = vec![Constraint::Length(header_height), Constraint::Min(1)];
50 constraints.push(Constraint::Length(input_height));
51
52 let segments = Layout::vertical(constraints).split(viewport);
53
54 let header_area = segments[0];
55 let main_area = segments[1];
56 let input_index = segments.len().saturating_sub(1);
57 let input_area = segments[input_index];
58
59 let _available_width = main_area.width;
60 let _horizontal_minimum = ui::INLINE_CONTENT_MIN_WIDTH + ui::INLINE_NAVIGATION_MIN_WIDTH;
61
62 let modal_in_bottom = matches!(panel.kind, BottomPanelKind::InlineModal);
63 let (transcript_area, modal_area) = if modal_in_bottom {
64 (main_area, None)
65 } else {
66 render::split_inline_modal_area(self, main_area)
67 };
68 self.set_modal_list_area(None);
69 let (input_area, bottom_panel_area) =
70 split_input_and_bottom_panel_area(input_area, panel.height);
71 self.set_bottom_panel_area(bottom_panel_area);
72 let navigation_area = Rect::new(main_area.x, main_area.y, 0, 0); SessionWidget::new(self)
76 .header_lines(header_lines.clone())
77 .header_area(header_area)
78 .transcript_area(transcript_area)
79 .navigation_area(navigation_area) .render(viewport, frame.buffer_mut());
81
82 self.render_input(frame, input_area);
86 let mut rendered_bottom_modal = false;
87 if !modal_in_bottom {
88 if let Some(modal_area) = modal_area {
89 render::render_modal(self, frame, modal_area);
90 } else {
91 render::render_modal(self, frame, viewport);
92 }
93 }
94 if let Some(panel_area) = bottom_panel_area {
95 match panel.kind {
96 BottomPanelKind::InlineModal => {
97 frame.render_widget(Clear, panel_area);
98 render::render_modal(self, frame, panel_area);
99 rendered_bottom_modal = true;
100 }
101 BottomPanelKind::FilePalette => {
102 render::render_file_palette(self, frame, panel_area);
103 }
104 BottomPanelKind::HistoryPicker => {
105 render::render_history_picker(self, frame, panel_area);
106 }
107 BottomPanelKind::SlashPalette => {
108 slash::render_slash_palette(self, frame, panel_area);
109 }
110 BottomPanelKind::TaskPanel => {
111 render_task_panel(self, frame, panel_area);
112 }
113 BottomPanelKind::None => {
114 frame.render_widget(Clear, panel_area);
115 }
116 }
117 }
118 if modal_in_bottom && !rendered_bottom_modal {
119 render::render_modal(self, frame, viewport);
120 }
121
122 if self.diff_preview_state().is_some() {
124 diff_preview::render_diff_preview(self, frame, viewport);
125 }
126
127 if self.mouse_selection.has_selection || self.mouse_selection.is_selecting {
129 self.mouse_selection
130 .apply_highlight(frame.buffer_mut(), viewport);
131
132 if self.mouse_selection.needs_copy() {
134 let text = self
135 .mouse_selection
136 .extract_text(frame.buffer_mut(), viewport);
137 if !text.is_empty() {
138 MouseSelectionState::copy_to_clipboard(&text);
139 }
140 self.mouse_selection.mark_copied();
141 }
142 }
143 }
144
145 #[allow(dead_code)]
146 pub(crate) fn render_message_spans(&self, index: usize) -> Vec<Span<'static>> {
147 let Some(line) = self.lines.get(index) else {
148 return vec![Span::raw(String::new())];
149 };
150 message_renderer::render_message_spans(
151 line,
152 &self.theme,
153 &self.labels,
154 |kind| self.prefix_text(kind),
155 |line| self.prefix_style(line),
156 |kind| self.text_fallback(kind),
157 )
158 }
159}
160
161fn resolve_bottom_panel_spec(
162 session: &mut Session,
163 viewport: Rect,
164 header_height: u16,
165 input_reserved_height: u16,
166) -> BottomPanelSpec {
167 let max_panel_height = viewport
168 .height
169 .saturating_sub(header_height)
170 .saturating_sub(input_reserved_height)
171 .saturating_sub(1);
172 if max_panel_height == 0 || viewport.width == 0 {
173 return BottomPanelSpec {
174 kind: BottomPanelKind::None,
175 height: 0,
176 };
177 }
178
179 if session.inline_lists_visible() {
180 let split_context = SplitContext {
181 width: viewport.width,
182 max_panel_height,
183 };
184 if modal_eligible_for_inline_bottom(session) {
185 if let Some(panel) = panel_from_split(
186 session,
187 split_context,
188 BottomPanelKind::InlineModal,
189 split_inline_modal_area_probe,
190 ) {
191 return panel;
192 }
193 } else if session.file_palette_active {
194 if let Some(panel) = panel_from_split(
195 session,
196 split_context,
197 BottomPanelKind::FilePalette,
198 render::split_inline_file_palette_area,
199 ) {
200 return panel;
201 }
202 } else if session.history_picker_state.active {
203 if let Some(panel) = panel_from_split(
204 session,
205 split_context,
206 BottomPanelKind::HistoryPicker,
207 render::split_inline_history_picker_area,
208 ) {
209 return panel;
210 }
211 } else if !session.slash_palette.is_empty()
212 && let Some(panel) = panel_from_split(
213 session,
214 split_context,
215 BottomPanelKind::SlashPalette,
216 slash::split_inline_slash_area,
217 )
218 {
219 return panel;
220 }
221 }
222
223 if session.show_task_panel
224 && let Some(panel) = panel_from_split(
225 session,
226 SplitContext {
227 width: viewport.width,
228 max_panel_height,
229 },
230 BottomPanelKind::TaskPanel,
231 split_inline_task_panel_area,
232 )
233 {
234 return panel;
235 }
236
237 BottomPanelSpec {
238 kind: BottomPanelKind::None,
239 height: 0,
240 }
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244struct SplitContext {
245 width: u16,
246 max_panel_height: u16,
247}
248
249fn panel_from_split(
250 session: &mut Session,
251 ctx: SplitContext,
252 kind: BottomPanelKind,
253 split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
254) -> Option<BottomPanelSpec> {
255 let height = probe_panel_height(session, ctx, split_fn);
256 if height == 0 {
257 None
258 } else {
259 Some(BottomPanelSpec {
260 kind,
261 height: normalize_panel_height(height, ctx.max_panel_height),
262 })
263 }
264}
265
266fn normalize_panel_height(raw_height: u16, max_panel_height: u16) -> u16 {
267 if raw_height == 0 || max_panel_height == 0 {
268 return 0;
269 }
270
271 let min_floor = ui::INLINE_LIST_PANEL_MIN_HEIGHT
272 .min(max_panel_height)
273 .max(1);
274 raw_height.max(min_floor).min(max_panel_height)
275}
276
277fn modal_eligible_for_inline_bottom(session: &Session) -> bool {
278 session.wizard_overlay().is_some()
279 || session
280 .modal_state()
281 .is_some_and(|modal| modal.list.is_some())
282}
283
284fn split_inline_modal_area_probe(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
285 render::split_inline_modal_area(session, area)
286}
287
288fn split_inline_task_panel_area(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
289 let visible_lines = session.task_panel_lines.len().max(1);
290 let desired_list_rows =
291 list_panel::rows_to_u16(visible_lines.min(ui::INLINE_LIST_MAX_ROWS_MULTILINE));
292 let fixed_rows = list_panel::fixed_section_rows(1, 1, false);
293 list_panel::split_bottom_list_panel(area, fixed_rows, desired_list_rows)
294}
295
296fn render_task_panel(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
297 if area.width == 0 || area.height == 0 {
298 return;
299 }
300
301 let rows = if session.task_panel_lines.is_empty() {
302 vec![(
303 inline_list::InlineListRow::single(
304 ui::PLAN_STATUS_EMPTY.to_string().into(),
305 session.header_secondary_style(),
306 ),
307 1,
308 )]
309 } else {
310 session
311 .task_panel_lines
312 .iter()
313 .map(|line| {
314 (
315 inline_list::InlineListRow::single(
316 line.clone().into(),
317 session.header_secondary_style(),
318 ),
319 1,
320 )
321 })
322 .collect()
323 };
324 let item_count = session.task_panel_lines.len();
325 let sections = list_panel::SharedListPanelSections {
326 header: vec![Line::from(vec![Span::styled(
327 ui::PLAN_BLOCK_TITLE.to_string(),
328 session.section_title_style(),
329 )])],
330 info: vec![Line::from(format!(
331 "{} item{}",
332 item_count,
333 if item_count == 1 { "" } else { "s" }
334 ))],
335 search: None,
336 };
337 let styles = list_panel::SharedListPanelStyles {
338 base_style: session.styles.default_style(),
339 selected_style: Some(session.styles.modal_list_highlight_style()),
340 text_style: session.header_secondary_style(),
341 };
342 let mut model = list_panel::StaticRowsListPanelModel {
343 rows,
344 selected: None,
345 offset: 0,
346 visible_rows: area.height as usize,
347 };
348 list_panel::render_shared_list_panel(frame, area, sections, styles, &mut model);
349}
350
351fn probe_panel_height(
352 session: &mut Session,
353 ctx: SplitContext,
354 split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
355) -> u16 {
356 if ctx.width == 0 || ctx.max_panel_height == 0 {
357 return 0;
358 }
359
360 let probe_area = Rect::new(0, 0, ctx.width, ctx.max_panel_height.saturating_add(1));
361 let (_, panel_area) = split_fn(session, probe_area);
362 panel_area.map(|area| area.height).unwrap_or(0)
363}
364
365fn split_input_and_bottom_panel_area(area: Rect, panel_height: u16) -> (Rect, Option<Rect>) {
366 if area.height == 0 || panel_height == 0 || area.height <= 1 {
367 return (area, None);
368 }
369
370 let resolved_panel = panel_height.min(area.height.saturating_sub(1));
371 if resolved_panel == 0 {
372 return (area, None);
373 }
374
375 let input_height = area.height.saturating_sub(resolved_panel);
376 let chunks = Layout::vertical([
377 Constraint::Length(input_height.max(1)),
378 Constraint::Length(resolved_panel),
379 ])
380 .split(area);
381 (chunks[0], Some(chunks[1]))
382}