vtcode_tui/core_tui/widgets/
session.rs1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 widgets::Widget,
5};
6
7use super::{
8 FooterWidget, HeaderWidget, LayoutMode, SidebarWidget, TranscriptWidget, footer_hints,
9 panel::new_panel,
10};
11use crate::ui::tui::session::Session;
12
13pub struct SessionWidget<'a> {
40 session: &'a mut Session,
41 header_lines: Option<Vec<ratatui::text::Line<'static>>>,
42 header_area: Option<Rect>,
43 transcript_area: Option<Rect>,
44 navigation_area: Option<Rect>,
45 layout_mode: Option<LayoutMode>,
46 footer_hint_override: Option<&'static str>,
47}
48
49impl<'a> SessionWidget<'a> {
50 pub fn new(session: &'a mut Session) -> Self {
52 Self {
53 session,
54 header_lines: None,
55 header_area: None,
56 transcript_area: None,
57 navigation_area: None,
58 layout_mode: None,
59 footer_hint_override: None,
60 }
61 }
62
63 #[must_use]
65 pub fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
66 self.header_lines = Some(lines);
67 self
68 }
69
70 #[must_use]
72 pub fn header_area(mut self, area: Rect) -> Self {
73 self.header_area = Some(area);
74 self
75 }
76
77 #[must_use]
79 pub fn transcript_area(mut self, area: Rect) -> Self {
80 self.transcript_area = Some(area);
81 self
82 }
83
84 #[must_use]
86 pub fn navigation_area(mut self, area: Rect) -> Self {
87 self.navigation_area = Some(area);
88 self
89 }
90
91 #[must_use]
93 pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
94 self.layout_mode = Some(mode);
95 self
96 }
97
98 #[must_use]
100 pub fn footer_hint_override(mut self, hint: &'static str) -> Self {
101 self.footer_hint_override = Some(hint);
102 self
103 }
104
105 fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
108 let footer_h = mode.footer_height();
109 let max_header_pct = mode.max_header_percent();
110
111 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
113 lines.clone()
114 } else {
115 self.session.header_lines()
116 };
117
118 let natural_header_h = self
119 .session
120 .header_height_from_lines(area.width, &header_lines);
121 let max_header_h = ((area.height as f32) * max_header_pct) as u16;
122 let header_h = natural_header_h.min(max_header_h).max(1);
123
124 let main_h = area.height.saturating_sub(header_h + footer_h);
126
127 let [header_area, main_area, footer_area] = Layout::vertical([
128 Constraint::Length(header_h),
129 Constraint::Length(main_h),
130 Constraint::Length(footer_h),
131 ])
132 .split(area)[..] else {
133 return SessionLayout {
134 header: Rect::ZERO,
135 main: Rect::ZERO,
136 sidebar: None,
137 footer: Rect::ZERO,
138 mode,
139 };
140 };
141
142 let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
145 if show_sidebar {
146 let sidebar_pct = mode.sidebar_width_percent();
147 let [left, right] = Layout::horizontal([
148 Constraint::Percentage(100 - sidebar_pct),
149 Constraint::Percentage(sidebar_pct),
150 ])
151 .split(main_area)[..] else {
152 return SessionLayout {
153 header: header_area,
154 main: main_area,
155 sidebar: None,
156 footer: footer_area,
157 mode,
158 };
159 };
160 return SessionLayout {
161 header: header_area,
162 main: left,
163 sidebar: Some(right),
164 footer: footer_area,
165 mode,
166 };
167 }
168
169 SessionLayout {
170 header: header_area,
171 main: main_area,
172 sidebar: None,
173 footer: footer_area,
174 mode,
175 }
176 }
177}
178
179struct SessionLayout {
181 header: Rect,
182 main: Rect,
183 sidebar: Option<Rect>,
184 footer: Rect,
185 #[expect(dead_code)]
186 mode: LayoutMode,
187}
188
189impl Widget for &mut SessionWidget<'_> {
190 fn render(self, area: Rect, buf: &mut Buffer) {
191 if area.width == 0 || area.height == 0 {
192 return;
193 }
194
195 let mode = self
197 .layout_mode
198 .unwrap_or_else(|| self.session.resolved_layout_mode(area));
199
200 if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area)
201 {
202 self.session.poll_log_entries();
203
204 if header_area.width > 0 && header_area.height > 0 {
205 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
206 lines.clone()
207 } else {
208 self.session.header_lines()
209 };
210 HeaderWidget::new(self.session)
211 .lines(header_lines)
212 .render(header_area, buf);
213 }
214
215 if transcript_area.width > 0 && transcript_area.height > 0 {
216 self.session.apply_view_rows(transcript_area.height);
217 let has_logs =
218 self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
219 if has_logs {
220 let chunks =
221 Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
222 .split(transcript_area);
223 TranscriptWidget::new(self.session).render(chunks[0], buf);
224 self.render_logs(chunks[1], buf, mode);
225 } else {
226 TranscriptWidget::new(self.session).render(transcript_area, buf);
227 }
228 }
229
230 if let Some(sidebar_area) = self.navigation_area
231 && sidebar_area.width > 0
232 && sidebar_area.height > 0
233 {
234 self.render_sidebar(sidebar_area, buf, mode);
235 }
236 return;
237 }
238
239 let layout_height = area.height.saturating_sub(self.session.input_height);
241 let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
242 if layout_area.height == 0 || layout_area.width == 0 {
243 return;
244 }
245
246 self.session.poll_log_entries();
248
249 let layout = self.compute_layout(layout_area, mode);
251
252 if layout.header.height != self.session.header_rows {
254 self.session.header_rows = layout.header.height;
255 self.session.recalculate_transcript_rows();
256 }
257
258 self.session.apply_view_rows(layout.main.height);
260
261 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
263 lines.clone()
264 } else {
265 self.session.header_lines()
266 };
267 HeaderWidget::new(self.session)
268 .lines(header_lines)
269 .render(layout.header, buf);
270
271 let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
273
274 if has_logs {
275 let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
276 .split(layout.main);
277 TranscriptWidget::new(self.session).render(chunks[0], buf);
278 self.render_logs(chunks[1], buf, mode);
279 } else {
280 TranscriptWidget::new(self.session).render(layout.main, buf);
281 }
282
283 if let Some(sidebar_area) = layout.sidebar {
285 self.render_sidebar(sidebar_area, buf, mode);
286 }
287
288 if mode.show_footer() && layout.footer.height > 0 {
290 self.render_footer(layout.footer, buf, mode);
291 }
292 }
293}
294
295impl<'a> SessionWidget<'a> {
296 fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
297 use ratatui::widgets::{Paragraph, Wrap};
298
299 let inner = new_panel(&self.session.styles)
300 .title("Logs")
301 .active(false)
302 .mode(mode)
303 .render_and_get_inner(area, buf);
304
305 if inner.height == 0 || inner.width == 0 {
306 return;
307 }
308
309 let paragraph =
310 Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
311 paragraph.render(inner, buf);
312 }
313
314 fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
315 let queue_items: Vec<String> =
316 if let Some(cached) = &self.session.queued_inputs_preview_cache {
317 cached.clone()
318 } else {
319 let items: Vec<String> = self
320 .session
321 .queued_inputs
322 .iter()
323 .take(5)
324 .map(|input| {
325 if input.chars().count() > 50 {
326 let preview: String = input.chars().take(50).collect();
327 format!("{preview}{}", vtcode_design::constants::ELLIPSIS)
328 } else {
329 input.clone()
330 }
331 })
332 .collect();
333 self.session.queued_inputs_preview_cache = Some(items.clone());
334 items
335 };
336
337 let context_info = self
338 .session
339 .input_status_right
340 .as_deref()
341 .unwrap_or("Ready");
342
343 SidebarWidget::new(&self.session.styles)
344 .local_agents(self.session.local_agents.clone())
345 .queue_items(queue_items)
346 .context_info(context_info)
347 .mode(mode)
348 .render(area, buf);
349 }
350
351 fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
352 let left_status = self.session.status_left_text().unwrap_or("");
353 let right_status = self.session.status_right_text().unwrap_or("");
354
355 let hint = if let Some(hint) = self.footer_hint_override {
356 hint
357 } else if self.session.thinking_spinner.is_active
358 || self.session.has_status_spinner()
359 || self.session.is_running_activity()
360 {
361 footer_hints::PROCESSING
362 } else if self.session.has_active_overlay() {
363 footer_hints::MODAL
364 } else if self.session.input_manager.content().is_empty() {
365 footer_hints::IDLE
366 } else {
367 footer_hints::EDITING
368 };
369
370 let shimmer_phase = self
371 .session
372 .is_shimmer_active()
373 .then_some(self.session.shimmer_state.phase());
374
375 let mut footer = FooterWidget::new(&self.session.styles)
376 .left_status(left_status)
377 .right_status(&right_status)
378 .hint(hint)
379 .mode(mode);
380
381 if self.session.thinking_spinner.is_active {
382 footer = footer.spinner(self.session.thinking_spinner.current_frame());
383 }
384
385 if let Some(phase) = shimmer_phase {
386 footer = footer.shimmer_phase(phase);
387 }
388
389 footer.render(area, buf);
390 }
391}
392
393#[expect(dead_code)]
394fn has_input_status(session: &Session) -> bool {
395 let left_present = session
396 .input_status_left
397 .as_ref()
398 .is_some_and(|value| !value.trim().is_empty());
399 if left_present {
400 return true;
401 }
402 session
403 .input_status_right
404 .as_ref()
405 .is_some_and(|value| !value.trim().is_empty())
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::core_tui::types::{InlineMessageKind, InlineSegment, InlineTextStyle, InlineTheme};
412 use std::sync::Arc;
413
414 fn segment(text: &str) -> InlineSegment {
415 InlineSegment {
416 text: text.to_string(),
417 style: Arc::new(InlineTextStyle::default()),
418 }
419 }
420
421 #[test]
422 fn auto_layout_resize_recomputes_transcript_area_and_keeps_content_visible() {
423 let wide_area = Rect::new(0, 0, 120, 24);
424 let standard_area = Rect::new(0, 0, 100, 24);
425 let mut wide_buf = Buffer::empty(wide_area);
426 let mut standard_buf = Buffer::empty(standard_area);
427 let mut session = Session::new(InlineTheme::default(), None, 24);
428
429 for index in 0..8 {
430 session.push_line(
431 InlineMessageKind::Agent,
432 vec![segment(&format!("line {index}"))],
433 );
434 }
435
436 let mut wide_widget = SessionWidget::new(&mut session);
437 (&mut wide_widget).render(wide_area, &mut wide_buf);
438 let wide_transcript = session.transcript_area().expect("wide transcript area");
439
440 let mut standard_widget = SessionWidget::new(&mut session);
441 (&mut standard_widget).render(standard_area, &mut standard_buf);
442 let standard_transcript = session.transcript_area().expect("standard transcript area");
443
444 assert!(wide_transcript.width < standard_transcript.width);
445 assert!(standard_transcript.height > 0);
446 }
447}