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, Panel, SidebarWidget, TranscriptWidget, footer_hints,
9};
10use crate::ui::tui::session::Session;
11
12pub struct SessionWidget<'a> {
39 session: &'a mut Session,
40 header_lines: Option<Vec<ratatui::text::Line<'static>>>,
41 header_area: Option<Rect>,
42 transcript_area: Option<Rect>,
43 navigation_area: Option<Rect>,
44 layout_mode: Option<LayoutMode>,
45 footer_hint_override: Option<&'static str>,
46}
47
48impl<'a> SessionWidget<'a> {
49 pub fn new(session: &'a mut Session) -> Self {
51 Self {
52 session,
53 header_lines: None,
54 header_area: None,
55 transcript_area: None,
56 navigation_area: None,
57 layout_mode: None,
58 footer_hint_override: None,
59 }
60 }
61
62 #[must_use]
64 pub fn header_lines(mut self, lines: Vec<ratatui::text::Line<'static>>) -> Self {
65 self.header_lines = Some(lines);
66 self
67 }
68
69 #[must_use]
71 pub fn header_area(mut self, area: Rect) -> Self {
72 self.header_area = Some(area);
73 self
74 }
75
76 #[must_use]
78 pub fn transcript_area(mut self, area: Rect) -> Self {
79 self.transcript_area = Some(area);
80 self
81 }
82
83 #[must_use]
85 pub fn navigation_area(mut self, area: Rect) -> Self {
86 self.navigation_area = Some(area);
87 self
88 }
89
90 #[must_use]
92 pub fn layout_mode(mut self, mode: LayoutMode) -> Self {
93 self.layout_mode = Some(mode);
94 self
95 }
96
97 #[must_use]
99 pub fn footer_hint_override(mut self, hint: &'static str) -> Self {
100 self.footer_hint_override = Some(hint);
101 self
102 }
103
104 fn compute_layout(&mut self, area: Rect, mode: LayoutMode) -> SessionLayout {
107 let footer_h = mode.footer_height();
108 let max_header_pct = mode.max_header_percent();
109
110 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
112 lines.clone()
113 } else {
114 self.session.header_lines()
115 };
116
117 let natural_header_h = self
118 .session
119 .header_height_from_lines(area.width, &header_lines);
120 let max_header_h = ((area.height as f32) * max_header_pct) as u16;
121 let header_h = natural_header_h.min(max_header_h).max(1);
122
123 let main_h = area.height.saturating_sub(header_h + footer_h);
125
126 let [header_area, main_area, footer_area] = Layout::vertical([
127 Constraint::Length(header_h),
128 Constraint::Length(main_h),
129 Constraint::Length(footer_h),
130 ])
131 .split(area)[..] else {
132 return SessionLayout {
133 header: Rect::ZERO,
134 main: Rect::ZERO,
135 sidebar: None,
136 footer: Rect::ZERO,
137 mode,
138 };
139 };
140
141 let show_sidebar = mode.allow_sidebar() && self.session.appearance.should_show_sidebar();
144 if show_sidebar {
145 let sidebar_pct = mode.sidebar_width_percent();
146 let [left, right] = Layout::horizontal([
147 Constraint::Percentage(100 - sidebar_pct),
148 Constraint::Percentage(sidebar_pct),
149 ])
150 .split(main_area)[..] else {
151 return SessionLayout {
152 header: header_area,
153 main: main_area,
154 sidebar: None,
155 footer: footer_area,
156 mode,
157 };
158 };
159 return SessionLayout {
160 header: header_area,
161 main: left,
162 sidebar: Some(right),
163 footer: footer_area,
164 mode,
165 };
166 }
167
168 SessionLayout {
169 header: header_area,
170 main: main_area,
171 sidebar: None,
172 footer: footer_area,
173 mode,
174 }
175 }
176}
177
178struct SessionLayout {
180 header: Rect,
181 main: Rect,
182 sidebar: Option<Rect>,
183 footer: Rect,
184 #[allow(dead_code)]
185 mode: LayoutMode,
186}
187
188impl Widget for &mut SessionWidget<'_> {
189 fn render(self, area: Rect, buf: &mut Buffer) {
190 if area.width == 0 || area.height == 0 {
191 return;
192 }
193
194 let mode = self
196 .layout_mode
197 .unwrap_or_else(|| self.session.resolved_layout_mode(area));
198
199 if let (Some(header_area), Some(transcript_area)) = (self.header_area, self.transcript_area)
200 {
201 self.session.poll_log_entries();
202
203 if header_area.width > 0 && header_area.height > 0 {
204 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
205 lines.clone()
206 } else {
207 self.session.header_lines()
208 };
209 HeaderWidget::new(self.session)
210 .lines(header_lines)
211 .render(header_area, buf);
212 }
213
214 if transcript_area.width > 0 && transcript_area.height > 0 {
215 self.session.apply_view_rows(transcript_area.height);
216 let has_logs =
217 self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
218 if has_logs {
219 let chunks =
220 Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
221 .split(transcript_area);
222 TranscriptWidget::new(self.session).render(chunks[0], buf);
223 self.render_logs(chunks[1], buf, mode);
224 } else {
225 TranscriptWidget::new(self.session).render(transcript_area, buf);
226 }
227 }
228
229 if let Some(sidebar_area) = self.navigation_area
230 && sidebar_area.width > 0
231 && sidebar_area.height > 0
232 {
233 self.render_sidebar(sidebar_area, buf, mode);
234 }
235 return;
236 }
237
238 let layout_height = area.height.saturating_sub(self.session.input_height);
240 let layout_area = Rect::new(area.x, area.y, area.width, layout_height);
241 if layout_area.height == 0 || layout_area.width == 0 {
242 return;
243 }
244
245 self.session.poll_log_entries();
247
248 let layout = self.compute_layout(layout_area, mode);
250
251 if layout.header.height != self.session.header_rows {
253 self.session.header_rows = layout.header.height;
254 self.session.recalculate_transcript_rows();
255 }
256
257 self.session.apply_view_rows(layout.main.height);
259
260 let header_lines = if let Some(lines) = self.header_lines.as_ref() {
262 lines.clone()
263 } else {
264 self.session.header_lines()
265 };
266 HeaderWidget::new(self.session)
267 .lines(header_lines)
268 .render(layout.header, buf);
269
270 let has_logs = self.session.show_logs && self.session.has_logs() && mode.show_logs_panel();
272
273 if has_logs {
274 let chunks = Layout::vertical([Constraint::Percentage(70), Constraint::Percentage(30)])
275 .split(layout.main);
276 TranscriptWidget::new(self.session).render(chunks[0], buf);
277 self.render_logs(chunks[1], buf, mode);
278 } else {
279 TranscriptWidget::new(self.session).render(layout.main, buf);
280 }
281
282 if let Some(sidebar_area) = layout.sidebar {
284 self.render_sidebar(sidebar_area, buf, mode);
285 }
286
287 if mode.show_footer() && layout.footer.height > 0 {
289 self.render_footer(layout.footer, buf, mode);
290 }
291 }
292}
293
294impl<'a> SessionWidget<'a> {
295 fn render_logs(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
296 use ratatui::widgets::{Paragraph, Wrap};
297
298 let inner = Panel::new(&self.session.styles)
299 .title("Logs")
300 .active(false)
301 .mode(mode)
302 .render_and_get_inner(area, buf);
303
304 if inner.height == 0 || inner.width == 0 {
305 return;
306 }
307
308 let paragraph =
309 Paragraph::new((*self.session.log_text()).clone()).wrap(Wrap { trim: false });
310 paragraph.render(inner, buf);
311 }
312
313 fn render_sidebar(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
314 let queue_items: Vec<String> =
315 if let Some(cached) = &self.session.queued_inputs_preview_cache {
316 cached.clone()
317 } else {
318 let items: Vec<String> = self
319 .session
320 .queued_inputs
321 .iter()
322 .take(5)
323 .map(|input| {
324 let preview: String = input.chars().take(50).collect();
325 if input.len() > 50 {
326 format!("{}...", preview)
327 } else {
328 preview
329 }
330 })
331 .collect();
332 self.session.queued_inputs_preview_cache = Some(items.clone());
333 items
334 };
335
336 let context_info = self
337 .session
338 .input_status_right
339 .as_deref()
340 .unwrap_or("Ready");
341
342 SidebarWidget::new(&self.session.styles)
343 .queue_items(queue_items)
344 .context_info(context_info)
345 .mode(mode)
346 .render(area, buf);
347 }
348
349 fn render_footer(&mut self, area: Rect, buf: &mut Buffer, mode: LayoutMode) {
350 let left_status = self.session.input_status_left.as_deref().unwrap_or("");
351 let right_status = self.session.input_status_right.as_deref().unwrap_or("");
352
353 let hint = if let Some(hint) = self.footer_hint_override {
354 hint
355 } else if self.session.thinking_spinner.is_active {
356 footer_hints::PROCESSING
357 } else if self.session.has_active_overlay() {
358 footer_hints::MODAL
359 } else if self.session.input_manager.content().is_empty() {
360 footer_hints::IDLE
361 } else {
362 footer_hints::EDITING
363 };
364
365 let input_status_visible = self
366 .session
367 .input_status_left
368 .as_ref()
369 .is_some_and(|value| !value.trim().is_empty())
370 || self
371 .session
372 .input_status_right
373 .as_ref()
374 .is_some_and(|value| !value.trim().is_empty());
375 let shimmer_phase = if input_status_visible {
376 None
377 } else {
378 Some(self.session.shimmer_state.phase())
379 };
380
381 let mut footer = FooterWidget::new(&self.session.styles)
382 .left_status(left_status)
383 .right_status(right_status)
384 .hint(hint)
385 .mode(mode);
386
387 if let Some(phase) = shimmer_phase {
388 footer = footer.shimmer_phase(phase);
389 }
390
391 footer.render(area, buf);
392 }
393}
394
395#[allow(dead_code)]
396fn has_input_status(session: &Session) -> bool {
397 let left_present = session
398 .input_status_left
399 .as_ref()
400 .is_some_and(|value| !value.trim().is_empty());
401 if left_present {
402 return true;
403 }
404 session
405 .input_status_right
406 .as_ref()
407 .is_some_and(|value| !value.trim().is_empty())
408}