1use std::collections::BTreeMap;
16
17use ratatui::buffer::Buffer;
18use ratatui::layout::Rect;
19#[cfg(test)]
20use ratatui::style::{Color, Modifier};
21
22use pi_ext::adapters::{SlotComponent, tui_overlay_spec};
23use pi_tui::component::Component;
24use pi_tui::components::Text;
25use pi_tui::focus::Focusable;
26
27use super::footer;
28use super::header;
29use super::messages::{self, MessageView};
30use super::progress;
31use super::startup;
32use super::state::{FocusArea, OverlayKind, ViewState, WidgetSlot};
33use super::status;
34use super::theme::{self, MarkdownTheme, ResolvedTheme, markdown_theme};
35
36pub struct ComposedSection {
38 pub label: &'static str,
40 pub component: Box<dyn Component>,
42}
43
44pub struct ComposedView {
46 pub sections: Vec<ComposedSection>,
49 pub overlay: Option<Box<dyn Component>>,
51 pub overlay_spec: Option<pi_tui::layout::OverlaySpec>,
53}
54
55#[must_use]
60pub fn compose(state: &ViewState) -> ComposedView {
61 theme::with_theme(state.theme.clone(), || compose_inner(state))
62}
63
64fn compose_inner(state: &ViewState) -> ComposedView {
65 let md_theme = markdown_theme();
66 let mut sections: Vec<ComposedSection> = Vec::new();
67
68 if !state.quiet {
70 sections.push(ComposedSection {
71 label: "header",
72 component: header::build_header(&state.header, md_theme.clone(), &state.theme),
73 });
74 }
75
76 sections.push(ComposedSection {
78 label: "resources",
79 component: startup::build_resources(&state.resources, &state.theme),
80 });
81
82 sections.push(ComposedSection {
84 label: "diagnostics",
85 component: startup::build_diagnostics(&state.diagnostics, &state.theme),
86 });
87
88 sections.push(ComposedSection {
90 label: "chat",
91 component: build_chat(state, &md_theme),
92 });
93
94 sections.push(ComposedSection {
96 label: "pending",
97 component: progress::build_pending(&state.pending, &state.theme),
98 });
99
100 sections.push(ComposedSection {
102 label: "status",
103 component: build_status_section(state),
104 });
105
106 sections.push(ComposedSection {
108 label: "widgets-above",
109 component: build_widget_stack(&state.widgets_above, &state.theme),
110 });
111
112 sections.push(ComposedSection {
114 label: "editor",
115 component: build_editor_section(state),
116 });
117
118 sections.push(ComposedSection {
120 label: "widgets-below",
121 component: build_widget_stack(&state.widgets_below, &state.theme),
122 });
123
124 sections.push(ComposedSection {
126 label: "footer",
127 component: footer::build_footer(&state.footer, &state.theme, state.width),
128 });
129
130 let overlay = build_overlay(state, &md_theme);
131 let overlay_spec = state
132 .extension_overlay_slot
133 .as_ref()
134 .and_then(|slot| slot.overlay_options.as_ref())
135 .map(tui_overlay_spec);
136
137 ComposedView {
138 sections,
139 overlay,
140 overlay_spec,
141 }
142}
143
144fn build_chat(state: &ViewState, md_theme: &MarkdownTheme) -> Box<dyn Component> {
146 let renderers: BTreeMap<String, Box<dyn super::tool_renderer::CustomToolRenderer>> =
147 BTreeMap::new();
148 let mut stack = messages::ColumnStack::new();
149 for msg in &state.messages {
150 let comps = build_message(msg, &renderers, md_theme, &state.theme);
151 for c in comps {
152 stack.push(c);
153 }
154 }
155 if state.messages.is_empty() && !state.streaming {
156 stack.push(Box::new(Text::with_padding(
158 state.theme.fg(
159 super::theme::ThemeColor::Dim,
160 "No messages yet. Type below to begin.",
161 ),
162 1,
163 0,
164 )));
165 }
166 Box::new(stack)
167}
168
169fn build_message(
171 msg: &MessageView,
172 renderers: &BTreeMap<String, Box<dyn super::tool_renderer::CustomToolRenderer>>,
173 md_theme: &MarkdownTheme,
174 th: &ResolvedTheme,
175) -> Vec<Box<dyn Component>> {
176 match msg {
177 MessageView::User(v) => vec![messages::build_user(v, md_theme, th)],
178 MessageView::Assistant(v) => messages::build_assistant(v, md_theme, th),
179 MessageView::Tool(v) => messages::build_tool(v, renderers, th),
180 MessageView::Bash(v) => vec![messages::build_bash(v, th)],
181 MessageView::Custom(v) => vec![messages::build_custom(v, md_theme, th)],
182 MessageView::Compaction(v) => vec![messages::build_compaction(v, md_theme, th)],
183 MessageView::Branch(v) => vec![messages::build_branch(v, md_theme, th)],
184 MessageView::Skill(v) => vec![messages::build_skill(v, md_theme, th)],
185 }
186}
187
188fn build_status_section(state: &ViewState) -> Box<dyn Component> {
190 if let Some(status) = state.status.as_ref() {
191 status::build_status(status, &state.theme)
192 } else {
193 status::build_idle(state.width)
194 }
195}
196
197fn build_editor_section(state: &ViewState) -> Box<dyn Component> {
199 if state.focus == FocusArea::Selector {
201 return Box::new(Text::with_padding(
204 state.theme.fg(super::theme::ThemeColor::Dim, "…"),
205 0,
206 0,
207 ));
208 }
209 let editor = &state.editor;
210 let display = if editor.text.is_empty() {
211 state
212 .theme
213 .fg(super::theme::ThemeColor::Dim, &editor.placeholder)
214 } else {
215 editor.text.clone()
216 };
217 let marker = editor.paste_marker.as_deref().unwrap_or("");
218 Box::new(Text::with_padding(format!("{display}{marker}"), 1, 0))
219}
220
221fn build_widget_stack(slots: &[WidgetSlot], _th: &ResolvedTheme) -> Box<dyn Component> {
223 let mut stack = messages::ColumnStack::new();
224 for widget in slots {
225 let mut component = SlotComponent::new(widget.slot.clone());
226 component.set_focused(widget.focused);
227 stack.push(Box::new(component));
228 }
229 if stack.is_empty() {
230 stack.push(Box::new(pi_tui::components::Spacer::new(0)));
231 }
232 Box::new(stack)
233}
234
235fn build_overlay(state: &ViewState, md_theme: &MarkdownTheme) -> Option<Box<dyn Component>> {
238 let overlay = state.overlay.as_ref()?;
239 let comp: Box<dyn Component> = match overlay.kind {
240 OverlayKind::ShortcutHelp => startup::build_shortcut_overlay(
241 &startup::default_shortcut_hints(),
242 &state.extension_shortcuts,
243 &state.theme,
244 ),
245 OverlayKind::Changelog => {
246 startup::build_changelog(&overlay.lines.join("\n"), md_theme.clone(), &state.theme)
247 }
248 OverlayKind::FirstTimeSetup => {
249 startup::build_first_time_setup(0, md_theme.clone(), &state.theme)
250 }
251 OverlayKind::Login => {
252 let mut stack = messages::ColumnStack::new();
253 for line in &overlay.lines {
254 stack.push(Box::new(Text::with_padding(line.clone(), 1, 0)));
255 }
256 Box::new(stack)
257 }
258 OverlayKind::Extension => {
259 let slot = state.extension_overlay_slot.as_ref()?;
260 let mut component = SlotComponent::new(slot.clone());
261 component.set_focused(
262 state.focus == FocusArea::Overlay
263 && !slot
264 .overlay_options
265 .as_ref()
266 .is_some_and(|options| options.non_capturing),
267 );
268 Box::new(component)
269 }
270 };
271 Some(comp)
272}
273
274#[must_use]
284pub fn render_view(state: &ViewState, width: u16, height: u16) -> Buffer {
285 render_view_with_height(state, width, height)
286}
287
288#[must_use]
291pub fn render_view_with_height(state: &ViewState, width: u16, height: u16) -> Buffer {
292 let composed = compose(state);
293 let area = Rect::new(0, 0, width.max(1), height.max(1));
294 let mut buf = Buffer::empty(area);
295 let mut y = 0u16;
296 for mut section in composed.sections {
298 let mut h = section.component.measure(width.max(1));
299 if h == 0 {
300 continue;
301 }
302 if y.saturating_add(h) > height {
303 h = height.saturating_sub(y);
304 if h == 0 {
305 break;
306 }
307 }
308 let rect = Rect::new(0, y, width.max(1), h);
309 let mut comp = section.component;
310 comp.render(rect, &mut buf);
311 y = y.saturating_add(h);
312 if y >= height {
313 break;
314 }
315 }
316 if let Some(mut overlay) = composed.overlay {
317 let measured = overlay.measure(width.max(1)).min(height);
318 let rect = composed.overlay_spec.as_ref().map_or_else(
319 || Rect::new(0, 0, width.max(1), measured),
320 |spec| {
321 let layout = pi_tui::layout::resolve_overlay_layout(
322 spec,
323 measured,
324 width.max(1),
325 height.max(1),
326 );
327 let overlay_height = layout
328 .max_height
329 .map_or(measured, |max_height| measured.min(max_height))
330 .min(height.saturating_sub(layout.row));
331 Rect::new(layout.col, layout.row, layout.width, overlay_height)
332 },
333 );
334 if rect.height > 0 {
335 overlay.render(rect, &mut buf);
336 }
337 }
338 buf
339}
340
341#[cfg(test)]
345#[must_use]
346pub fn render_component(comp: &mut dyn Component, width: u16) -> Buffer {
347 let h = comp.measure(width.max(1)).max(1);
348 let area = Rect::new(0, 0, width.max(1), h);
349 let mut buf = Buffer::empty(area);
350 comp.render(area, &mut buf);
351 buf
352}
353
354#[cfg(test)]
358#[must_use]
359pub fn snapshot_buffer_plain(buf: &Buffer, width: u16, height: u16) -> Vec<String> {
360 use ratatui::buffer::CellDiffOption;
361 let mut out = Vec::with_capacity(usize::from(height));
362 for row in 0..height {
363 let mut line = String::new();
364 for x in 0..width {
365 if let Some(cell) = buf.cell((x, row)) {
366 if cell.diff_option == CellDiffOption::Skip {
367 continue;
368 }
369 line.push_str(cell.symbol());
370 } else {
371 line.push(' ');
372 }
373 }
374 out.push(line);
375 }
376 out
377}
378
379#[cfg(test)]
384#[must_use]
385pub fn snapshot_buffer_ansi(
386 buf: &Buffer,
387 width: u16,
388 height: u16,
389 mode: super::theme::ColorMode,
390) -> Vec<String> {
391 use ratatui::style::{Color, Modifier};
392 let mut out = Vec::with_capacity(usize::from(height));
393 for row in 0..height {
394 let mut line = String::new();
395 let mut previous_foreground: Option<Color> = None;
396 let mut previous_background: Option<Color> = None;
397 let mut previous_style_modifiers = Modifier::empty();
398 let mut previous_style_set = false;
399 for x in 0..width {
400 if let Some(cell) = buf.cell((x, row)) {
401 let style = cell.style();
402 let foreground = style.fg;
403 let background = style.bg;
404 let style_modifiers = style.add_modifier;
405 if !previous_style_set
406 || foreground != previous_foreground
407 || background != previous_background
408 || style_modifiers != previous_style_modifiers
409 {
410 line.push_str("\x1b[0m");
411 if let Some(c) = foreground {
412 push_color(&mut line, c, true, mode);
413 }
414 if let Some(c) = background {
415 push_color(&mut line, c, false, mode);
416 }
417 push_style_modifiers(&mut line, style_modifiers);
418 previous_foreground = foreground;
419 previous_background = background;
420 previous_style_modifiers = style_modifiers;
421 previous_style_set = true;
422 }
423 line.push_str(cell.symbol());
424 } else {
425 line.push(' ');
426 }
427 }
428 if previous_style_set {
429 line.push_str("\x1b[0m");
430 }
431 out.push(line);
432 }
433 out
434}
435
436#[cfg(test)]
437fn push_color(
438 out: &mut String,
439 color: ratatui::style::Color,
440 fg: bool,
441 mode: super::theme::ColorMode,
442) {
443 use std::fmt::Write as _;
444 let prefix = if fg { 38 } else { 48 };
445 match color {
446 Color::Rgb(r, g, b) => match mode {
447 super::theme::ColorMode::Truecolor => {
448 let _ = write!(out, "\x1b[{prefix};2;{r};{g};{b}m");
449 }
450 super::theme::ColorMode::Palette256 => {
451 let idx = super::theme::rgb_to_256(super::theme::Rgb(r, g, b));
452 let _ = write!(out, "\x1b[{prefix};5;{idx}m");
453 }
454 },
455 Color::Indexed(i) => {
456 let _ = write!(out, "\x1b[{prefix};5;{i}m");
457 }
458 c => {
459 let idx = basic_color_index(c);
460 if idx < 16 {
461 let _ = write!(out, "\x1b[{prefix};5;{idx}m");
462 }
463 }
464 }
465}
466
467#[cfg(test)]
468fn basic_color_index(c: Color) -> u8 {
469 match c {
470 Color::Black => 0,
471 Color::Red => 1,
472 Color::Green => 2,
473 Color::Yellow => 3,
474 Color::Blue => 4,
475 Color::Magenta => 5,
476 Color::Cyan => 6,
477 Color::Gray => 7,
478 Color::DarkGray => 8,
479 Color::LightRed => 9,
480 Color::LightGreen => 10,
481 Color::LightYellow => 11,
482 Color::LightBlue => 12,
483 Color::LightMagenta => 13,
484 Color::LightCyan => 14,
485 Color::White => 15,
486 _ => 255,
487 }
488}
489
490#[cfg(test)]
491fn push_style_modifiers(out: &mut String, style_modifiers: Modifier) {
492 use ratatui::style::Modifier;
493 if style_modifiers.contains(Modifier::BOLD) {
494 out.push_str("\x1b[1m");
495 }
496 if style_modifiers.contains(Modifier::DIM) {
497 out.push_str("\x1b[2m");
498 }
499 if style_modifiers.contains(Modifier::ITALIC) {
500 out.push_str("\x1b[3m");
501 }
502 if style_modifiers.contains(Modifier::UNDERLINED) {
503 out.push_str("\x1b[4m");
504 }
505 if style_modifiers.contains(Modifier::REVERSED) {
506 out.push_str("\x1b[7m");
507 }
508 if style_modifiers.contains(Modifier::CROSSED_OUT) {
509 out.push_str("\x1b[9m");
510 }
511}