Skip to main content

pi/modes/interactive/
messages.rs

1//! Chat message view-models and component builders.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/modes/interactive/components/`
4//! `assistant-message.ts`, `user-message.ts`, `tool-execution.ts`,
5//! `bash-execution.ts`, `custom-message.ts`, `compaction-summary-message.ts`,
6//! `branch-summary-message.ts`, and `skill-invocation-message.ts` into pure
7//! view-models that build pi-tui components for composition.
8
9use 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};
19/// Default output padding for assistant/user content blocks (matches reference).
20pub const OUTPUT_PAD: u16 = 1;
21
22/// Collapsed bash/tool preview line count (ports `PREVIEW_LINES`).
23pub const BASH_PREVIEW_LINES: usize = 20;
24/// One chat message view-model.
25#[derive(Clone, Debug)]
26pub enum MessageView {
27    /// User-authored message.
28    User(UserMessageView),
29    /// Assistant message (text + thinking + stop-reason errors).
30    Assistant(AssistantMessageView),
31    /// Tool execution block.
32    Tool(ToolMessageView),
33    /// Bash execution (`!`/`!!`) block.
34    Bash(BashMessageView),
35    /// Extension-injected custom message.
36    Custom(CustomMessageView),
37    /// Compaction summary.
38    Compaction(CompactionSummaryView),
39    /// Branch summary.
40    Branch(BranchSummaryView),
41    /// Skill invocation block.
42    Skill(SkillInvocationView),
43}
44
45/// User message view-model.
46#[derive(Clone, Debug)]
47pub struct UserMessageView {
48    /// Markdown source.
49    pub text: String,
50}
51
52/// Assistant message view-model.
53#[derive(Clone, Debug)]
54pub struct AssistantMessageView {
55    /// The full assistant message (content + usage + stop reason).
56    pub message: AssistantMessage,
57    /// Whether thinking blocks are hidden behind a static label.
58    pub hide_thinking: bool,
59    /// Label shown for hidden thinking runs.
60    pub hidden_thinking_label: String,
61    /// Whether this is the live streaming tail.
62    pub streaming: bool,
63}
64
65/// Tool message view-model (wraps [`ToolState`] plus renderer key).
66#[derive(Clone, Debug)]
67pub struct ToolMessageView {
68    /// Aggregate tool state.
69    pub state: ToolState,
70    /// Renderer key (tool name) used to look up a [`super::tool_renderer::CustomToolRenderer`].
71    pub renderer: String,
72}
73
74/// Bash execution view-model.
75#[derive(Clone, Debug)]
76pub struct BashMessageView {
77    /// Shell command.
78    pub command: String,
79    /// Captured output (possibly truncated).
80    pub output: String,
81    /// Whether collapsed preview vs expanded.
82    pub expanded: bool,
83    /// Exit code when finished.
84    pub exit_code: Option<i32>,
85    /// Whether cancelled.
86    pub cancelled: bool,
87    /// Whether output is truncated.
88    pub truncated: bool,
89    /// Spill path when truncated.
90    pub full_output_path: Option<String>,
91}
92
93/// Extension custom message view-model.
94#[derive(Clone, Debug)]
95pub struct CustomMessageView {
96    /// Custom type label.
97    pub custom_type: String,
98    /// Text content.
99    pub text: String,
100}
101
102/// Compaction summary view-model.
103#[derive(Clone, Debug)]
104pub struct CompactionSummaryView {
105    /// Summary text.
106    pub summary: String,
107    /// Tokens before compaction.
108    pub tokens_before: i64,
109}
110
111/// Branch summary view-model.
112#[derive(Clone, Debug)]
113pub struct BranchSummaryView {
114    /// Summary text.
115    pub summary: String,
116    /// Entry id the branch forked from.
117    pub from_id: String,
118}
119
120/// Skill invocation view-model.
121#[derive(Clone, Debug)]
122pub struct SkillInvocationView {
123    /// Skill name.
124    pub name: String,
125    /// Invocation text.
126    pub text: String,
127}
128
129impl MessageView {
130    /// Build a streaming assistant tail view-model.
131    #[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/// Build the component stack for one assistant message.
143///
144/// Returns a `Vec<Box<dyn Component>>` so the caller can splice it into the
145/// chat container in order. Mirrors `AssistantMessageComponent.updateContent`.
146#[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                // Preserve the first Thinking content in the run (thinking-current-block fix).
196                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                // Tool calls render as separate Tool message blocks, not inline.
217            }
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/// Build the component for a user message (Box + Markdown on userMessageBg).
310#[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/// Build the component stack for a tool execution block.
332///
333/// Looks up `renderers` for the tool name; falls back to a JSON dump header.
334/// Call/result renderers return pre-styled lines, wrapped here in `Text`.
335#[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    // Call header (renderer or JSON fallback).
352    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    // Result body.
368    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/// Build the bash execution component (bordered run UI with preview/expand).
387#[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/// Build the custom-message component (purple label box).
439#[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/// Build the compaction-summary component (collapsible).
465#[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/// Build the branch-summary component.
489#[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/// Build the skill-invocation component.
513#[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
538/// Take the first `n` lines of `text` for a collapsed preview.
539fn preview_lines(text: &str, n: usize) -> String {
540    text.lines().take(n).collect::<Vec<_>>().join("\n")
541}
542
543/// User-message default text style (color applied via markdown theme hooks).
544fn user_text_style() -> pi_tui::components::DefaultTextStyle {
545    pi_tui::components::DefaultTextStyle::default()
546}
547
548/// Thinking text style (italic + thinkingText color applied via markdown theme).
549fn thinking_text_style() -> pi_tui::components::DefaultTextStyle {
550    pi_tui::components::DefaultTextStyle::with_style_flags(0)
551}
552
553/// Custom-message default text style.
554fn custom_text_style() -> pi_tui::components::DefaultTextStyle {
555    pi_tui::components::DefaultTextStyle::default()
556}
557
558// ---------------------------------------------------------------------------
559// ColumnStack: a minimal vertical stack component (no border).
560// ---------------------------------------------------------------------------
561
562/// Vertical stack of components; measure = sum of child heights, render stacks
563/// top-to-bottom. Used to assemble multi-block message bodies.
564pub struct ColumnStack {
565    children: Vec<Box<dyn Component>>,
566}
567
568impl ColumnStack {
569    /// Create an empty stack.
570    #[must_use]
571    pub fn new() -> Self {
572        Self {
573            children: Vec::new(),
574        }
575    }
576
577    /// Push a child.
578    pub fn push(&mut self, child: Box<dyn Component>) {
579        self.children.push(child);
580    }
581
582    /// Whether the stack has no children.
583    #[must_use]
584    pub fn is_empty(&self) -> bool {
585        self.children.is_empty()
586    }
587
588    /// Number of children.
589    #[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}