1use std::collections::BTreeMap;
10
11use pi_ai::{AssistantContent, AssistantMessage, StopReason};
12use pi_tui::component::Component;
13use pi_tui::components::{Markdown, Padded, Spacer, Text};
14
15use super::theme::{
16 self, MarkdownOptions, MarkdownTheme, ResolvedTheme, ThemeBg, ThemeColor, user_markdown_options,
17};
18use super::tool_renderer::{ToolPhase, ToolState};
19pub const OUTPUT_PAD: u16 = 1;
21
22pub const BASH_PREVIEW_LINES: usize = 20;
24#[derive(Clone, Debug)]
26pub enum MessageView {
27 User(UserMessageView),
29 Assistant(AssistantMessageView),
31 Tool(ToolMessageView),
33 Bash(BashMessageView),
35 Custom(CustomMessageView),
37 Compaction(CompactionSummaryView),
39 Branch(BranchSummaryView),
41 Skill(SkillInvocationView),
43}
44
45#[derive(Clone, Debug)]
47pub struct UserMessageView {
48 pub text: String,
50}
51
52#[derive(Clone, Debug)]
54pub struct AssistantMessageView {
55 pub message: AssistantMessage,
57 pub hide_thinking: bool,
59 pub hidden_thinking_label: String,
61 pub streaming: bool,
63}
64
65#[derive(Clone, Debug)]
67pub struct ToolMessageView {
68 pub state: ToolState,
70 pub renderer: String,
72}
73
74#[derive(Clone, Debug)]
76pub struct BashMessageView {
77 pub command: String,
79 pub output: String,
81 pub expanded: bool,
83 pub exit_code: Option<i32>,
85 pub cancelled: bool,
87 pub truncated: bool,
89 pub full_output_path: Option<String>,
91}
92
93#[derive(Clone, Debug)]
95pub struct CustomMessageView {
96 pub custom_type: String,
98 pub text: String,
100}
101
102#[derive(Clone, Debug)]
104pub struct CompactionSummaryView {
105 pub summary: String,
107 pub tokens_before: i64,
109}
110
111#[derive(Clone, Debug)]
113pub struct BranchSummaryView {
114 pub summary: String,
116 pub from_id: String,
118}
119
120#[derive(Clone, Debug)]
122pub struct SkillInvocationView {
123 pub name: String,
125 pub text: String,
127}
128
129impl MessageView {
130 #[must_use]
132 pub fn streaming_assistant(message: AssistantMessage) -> Self {
133 Self::Assistant(AssistantMessageView {
134 message,
135 hide_thinking: false,
136 hidden_thinking_label: "Thinking…".to_owned(),
137 streaming: true,
138 })
139 }
140}
141
142#[must_use]
147pub fn build_assistant(
148 view: &AssistantMessageView,
149 md_theme: &MarkdownTheme,
150 theme: &ResolvedTheme,
151) -> Vec<Box<dyn Component>> {
152 let mut out: Vec<Box<dyn Component>> = Vec::new();
153 let message = &view.message;
154
155 if message.content.iter().any(content_is_visible) {
156 out.push(Box::new(Spacer::new(1)));
157 }
158
159 push_assistant_content_blocks(&mut out, view, md_theme, theme);
160 push_assistant_stop_reason(&mut out, message, theme);
161 out
162}
163
164fn content_is_visible(content: &AssistantContent) -> bool {
165 match content {
166 AssistantContent::Text(t) => !t.text.read().trim().is_empty(),
167 AssistantContent::Thinking(t) => !t.thinking.read().trim().is_empty(),
168 AssistantContent::ToolCall(_) => false,
169 }
170}
171
172fn push_assistant_content_blocks(
173 out: &mut Vec<Box<dyn Component>>,
174 view: &AssistantMessageView,
175 md_theme: &MarkdownTheme,
176 theme: &ResolvedTheme,
177) {
178 let message = &view.message;
179 let mut iter = message.content.iter().enumerate().peekable();
180 while let Some((idx, content)) = iter.next() {
181 match content {
182 AssistantContent::Text(t) => {
183 if !t.text.read().trim().is_empty() {
184 out.push(Box::new(Markdown::new(
185 t.text.read().trim(),
186 OUTPUT_PAD,
187 0,
188 md_theme.clone(),
189 theme::default_text_style(),
190 MarkdownOptions::default(),
191 )));
192 }
193 }
194 AssistantContent::Thinking(tc) => {
195 let mut blocks: Vec<String> = Vec::new();
197 if !tc.thinking.read().trim().is_empty() {
198 blocks.push(tc.thinking.read().trim().to_owned());
199 }
200 while let Some(&(_, c)) = iter.peek() {
201 if let AssistantContent::Thinking(next) = c {
202 if !next.thinking.read().trim().is_empty() {
203 blocks.push(next.thinking.read().trim().to_owned());
204 }
205 iter.next();
206 } else {
207 break;
208 }
209 }
210 if blocks.is_empty() {
211 continue;
212 }
213 push_thinking_components(out, view, md_theme, theme, &blocks, idx);
214 }
215 AssistantContent::ToolCall(_) => {
216 }
218 }
219 }
220}
221
222fn push_thinking_components(
223 out: &mut Vec<Box<dyn Component>>,
224 view: &AssistantMessageView,
225 md_theme: &MarkdownTheme,
226 theme: &ResolvedTheme,
227 blocks: &[String],
228 idx: usize,
229) {
230 let has_after = view
231 .message
232 .content
233 .iter()
234 .skip(idx + 1)
235 .any(content_is_visible);
236 if view.hide_thinking {
237 out.push(Box::new(Text::with_padding(
238 theme.fg(
239 ThemeColor::ThinkingText,
240 &theme::italic(&view.hidden_thinking_label),
241 ),
242 OUTPUT_PAD,
243 0,
244 )));
245 } else {
246 out.push(Box::new(Markdown::new(
247 blocks.join("\n\n"),
248 OUTPUT_PAD,
249 0,
250 md_theme.clone(),
251 thinking_text_style(),
252 MarkdownOptions::default(),
253 )));
254 }
255 if has_after {
256 out.push(Box::new(Spacer::new(1)));
257 }
258}
259
260fn push_assistant_stop_reason(
261 out: &mut Vec<Box<dyn Component>>,
262 message: &AssistantMessage,
263 theme: &ResolvedTheme,
264) {
265 let has_tool_calls = message
266 .content
267 .iter()
268 .any(|c| matches!(c, AssistantContent::ToolCall(_)));
269 match message.stop_reason {
270 StopReason::Length => {
271 out.push(Box::new(Spacer::new(1)));
272 out.push(Box::new(Text::with_padding(
273 theme.fg(
274 ThemeColor::Error,
275 "Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
276 ),
277 OUTPUT_PAD,
278 0,
279 )));
280 }
281 StopReason::Aborted if !has_tool_calls => {
282 let msg = match message.error_message.as_deref() {
283 Some(e) if e != "Request was aborted" => e.to_owned(),
284 _ => "Operation aborted".to_owned(),
285 };
286 out.push(Box::new(Spacer::new(1)));
287 out.push(Box::new(Text::with_padding(
288 theme.fg(ThemeColor::Error, &msg),
289 OUTPUT_PAD,
290 0,
291 )));
292 }
293 StopReason::Error if !has_tool_calls => {
294 let msg = message
295 .error_message
296 .clone()
297 .unwrap_or_else(|| "Unknown error".to_owned());
298 out.push(Box::new(Spacer::new(1)));
299 out.push(Box::new(Text::with_padding(
300 theme.fg(ThemeColor::Error, &format!("Error: {msg}")),
301 OUTPUT_PAD,
302 0,
303 )));
304 }
305 _ => {}
306 }
307}
308
309#[must_use]
311pub fn build_user(
312 view: &UserMessageView,
313 md_theme: &MarkdownTheme,
314 theme: &ResolvedTheme,
315) -> Box<dyn Component> {
316 let mut box_ = Padded::with_padding(OUTPUT_PAD, 1);
317 let bg = theme.bg_ansi(ThemeBg::UserMessageBg);
318 box_.set_bg(Some(move |line: &str| format!("{bg}{line}\x1b[49m")));
319 let md = Markdown::new(
320 view.text.as_str(),
321 0,
322 0,
323 md_theme.clone(),
324 user_text_style(),
325 user_markdown_options(),
326 );
327 box_.add_child(md);
328 Box::new(box_)
329}
330
331#[must_use]
336pub fn build_tool(
337 view: &ToolMessageView,
338 renderers: &BTreeMap<String, Box<dyn super::tool_renderer::CustomToolRenderer>>,
339 theme: &ResolvedTheme,
340) -> Vec<Box<dyn Component>> {
341 let mut out: Vec<Box<dyn Component>> = Vec::new();
342 out.push(Box::new(Spacer::new(1)));
343 let phase_bg = match view.state.phase {
344 ToolPhase::Pending => ThemeBg::ToolPendingBg,
345 ToolPhase::Success => ThemeBg::ToolSuccessBg,
346 ToolPhase::Error => ThemeBg::ToolErrorBg,
347 };
348 let mut shell = Padded::with_padding(OUTPUT_PAD, 0);
349 let bg = theme.bg_ansi(phase_bg);
350 shell.set_bg(Some(move |line: &str| format!("{bg}{line}\x1b[49m")));
351 let header_lines: Vec<String> = if let Some(renderer) = renderers.get(&view.renderer) {
353 renderer
354 .render_call_lines(&view.state.call, view.state.expanded)
355 .unwrap_or_default()
356 } else {
357 let title = theme.fg(
358 ThemeColor::ToolTitle,
359 &format!("▶ {}", view.state.call.name),
360 );
361 let args = serde_json::to_string_pretty(&view.state.call.raw_args).unwrap_or_default();
362 vec![title, theme.fg(ThemeColor::ToolOutput, &args)]
363 };
364 if !header_lines.is_empty() {
365 shell.add_child(Text::with_padding(header_lines.join("\n"), 0, 0));
366 }
367 if let Some(result) = view.state.result.as_ref() {
369 let body_lines = if let Some(renderer) = renderers.get(&view.renderer) {
370 renderer.render_result_lines(result, view.state.expanded)
371 } else {
372 super::tool_renderer::default_result_lines(result)
373 };
374 if !body_lines.is_empty() {
375 shell.add_child(Text::with_padding(
376 theme.fg(ThemeColor::ToolOutput, &body_lines.join("\n")),
377 0,
378 0,
379 ));
380 }
381 }
382 out.push(Box::new(shell));
383 out
384}
385
386#[must_use]
388pub fn build_bash(view: &BashMessageView, theme: &ResolvedTheme) -> Box<dyn Component> {
389 let mut out: Vec<Box<dyn Component>> = Vec::new();
390 let cmd_line = theme.fg(ThemeColor::BashMode, &format!("$ {}", view.command));
391 out.push(Box::new(Text::with_padding(cmd_line, OUTPUT_PAD, 0)));
392 let body = if view.expanded {
393 view.output.clone()
394 } else {
395 preview_lines(&view.output, BASH_PREVIEW_LINES)
396 };
397 if !body.is_empty() {
398 out.push(Box::new(Text::with_padding(
399 theme.fg(ThemeColor::ToolOutput, &body),
400 OUTPUT_PAD,
401 0,
402 )));
403 }
404 if view.truncated
405 && !view.expanded
406 && let Some(path) = view.full_output_path.as_deref()
407 {
408 out.push(Box::new(Text::with_padding(
409 theme.fg(
410 ThemeColor::Dim,
411 &format!("[truncated — full output: {path}]"),
412 ),
413 OUTPUT_PAD,
414 0,
415 )));
416 }
417 if view.cancelled {
418 out.push(Box::new(Text::with_padding(
419 theme.fg(ThemeColor::Warning, "(cancelled)"),
420 OUTPUT_PAD,
421 0,
422 )));
423 } else if let Some(code) = view.exit_code.filter(|&code| code != 0) {
424 out.push(Box::new(Text::with_padding(
425 theme.fg(ThemeColor::Error, &format!("exit {code}")),
426 OUTPUT_PAD,
427 0,
428 )));
429 }
430 let mut stack = ColumnStack::new();
431 for c in out {
432 stack.push(c);
433 }
434 let _ = theme;
435 Box::new(stack)
436}
437
438#[must_use]
440pub fn build_custom(
441 view: &CustomMessageView,
442 md_theme: &MarkdownTheme,
443 theme: &ResolvedTheme,
444) -> Box<dyn Component> {
445 let mut box_ = Padded::with_padding(OUTPUT_PAD, 1);
446 let bg = theme.bg_ansi(ThemeBg::CustomMessageBg);
447 box_.set_bg(Some(move |line: &str| format!("{bg}{line}\x1b[49m")));
448 let label = theme.fg(
449 ThemeColor::CustomMessageLabel,
450 &format!("[{}]", view.custom_type),
451 );
452 box_.add_child(Text::with_padding(label, 0, 0));
453 box_.add_child(Markdown::new(
454 view.text.as_str(),
455 0,
456 0,
457 md_theme.clone(),
458 custom_text_style(),
459 MarkdownOptions::default(),
460 ));
461 Box::new(box_)
462}
463
464#[must_use]
466pub fn build_compaction(
467 view: &CompactionSummaryView,
468 md_theme: &MarkdownTheme,
469 theme: &ResolvedTheme,
470) -> Box<dyn Component> {
471 let mut stack = ColumnStack::new();
472 let label = theme.fg(
473 ThemeColor::Accent,
474 &format!("⌁ Compacted context (was {} tokens)", view.tokens_before),
475 );
476 stack.push(Box::new(Text::with_padding(label, OUTPUT_PAD, 0)));
477 stack.push(Box::new(Markdown::new(
478 view.summary.as_str(),
479 OUTPUT_PAD,
480 0,
481 md_theme.clone(),
482 theme::default_text_style(),
483 MarkdownOptions::default(),
484 )));
485 Box::new(stack)
486}
487
488#[must_use]
490pub fn build_branch(
491 view: &BranchSummaryView,
492 md_theme: &MarkdownTheme,
493 theme: &ResolvedTheme,
494) -> Box<dyn Component> {
495 let mut stack = ColumnStack::new();
496 let label = theme.fg(
497 ThemeColor::Accent,
498 &format!("↶ Branch summary (from {})", view.from_id),
499 );
500 stack.push(Box::new(Text::with_padding(label, OUTPUT_PAD, 0)));
501 stack.push(Box::new(Markdown::new(
502 view.summary.as_str(),
503 OUTPUT_PAD,
504 0,
505 md_theme.clone(),
506 theme::default_text_style(),
507 MarkdownOptions::default(),
508 )));
509 Box::new(stack)
510}
511
512#[must_use]
514pub fn build_skill(
515 view: &SkillInvocationView,
516 md_theme: &MarkdownTheme,
517 theme: &ResolvedTheme,
518) -> Box<dyn Component> {
519 let mut box_ = Padded::with_padding(OUTPUT_PAD, 1);
520 let bg = theme.bg_ansi(ThemeBg::CustomMessageBg);
521 box_.set_bg(Some(move |line: &str| format!("{bg}{line}\x1b[49m")));
522 let label = theme.fg(
523 ThemeColor::CustomMessageLabel,
524 &format!("[skill:{}]", view.name),
525 );
526 box_.add_child(Text::with_padding(label, 0, 0));
527 box_.add_child(Markdown::new(
528 view.text.as_str(),
529 0,
530 0,
531 md_theme.clone(),
532 custom_text_style(),
533 MarkdownOptions::default(),
534 ));
535 Box::new(box_)
536}
537
538fn preview_lines(text: &str, n: usize) -> String {
540 text.lines().take(n).collect::<Vec<_>>().join("\n")
541}
542
543fn user_text_style() -> pi_tui::components::DefaultTextStyle {
545 pi_tui::components::DefaultTextStyle::default()
546}
547
548fn thinking_text_style() -> pi_tui::components::DefaultTextStyle {
550 pi_tui::components::DefaultTextStyle::with_style_flags(0)
551}
552
553fn custom_text_style() -> pi_tui::components::DefaultTextStyle {
555 pi_tui::components::DefaultTextStyle::default()
556}
557
558pub struct ColumnStack {
565 children: Vec<Box<dyn Component>>,
566}
567
568impl ColumnStack {
569 #[must_use]
571 pub fn new() -> Self {
572 Self {
573 children: Vec::new(),
574 }
575 }
576
577 pub fn push(&mut self, child: Box<dyn Component>) {
579 self.children.push(child);
580 }
581
582 #[must_use]
584 pub fn is_empty(&self) -> bool {
585 self.children.is_empty()
586 }
587
588 #[must_use]
590 pub fn len(&self) -> usize {
591 self.children.len()
592 }
593}
594
595impl Default for ColumnStack {
596 fn default() -> Self {
597 Self::new()
598 }
599}
600
601impl Component for ColumnStack {
602 fn measure(&mut self, width: u16) -> u16 {
603 self.children.iter_mut().map(|c| c.measure(width)).sum()
604 }
605
606 fn render(&mut self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
607 let mut y = area.y;
608 for child in &mut self.children {
609 let h = child.measure(area.width);
610 if h == 0 {
611 continue;
612 }
613 let row = ratatui::layout::Rect {
614 x: area.x,
615 y,
616 width: area.width,
617 height: h,
618 };
619 child.render(row, buf);
620 y = y.saturating_add(h);
621 if y >= area.y.saturating_add(area.height) {
622 break;
623 }
624 }
625 }
626
627 fn handle_event(
628 &mut self,
629 event: &pi_tui::component::UiEvent,
630 ) -> pi_tui::component::EventResult {
631 let _ = event;
632 pi_tui::component::EventResult::Ignored
633 }
634
635 fn invalidate(&mut self) {
636 for c in &mut self.children {
637 c.invalidate();
638 }
639 }
640}