polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Transcript pane: the rendered message stream of the selected conversation.
//!
//! [`MessageLine`] is the FROZEN view type the transcript adapter
//! ([`crate::data::transcript`]) produces from either the streaming
//! `TurnEvent`s of `polyc-rpc-client` or the forensics transcript
//! projection. It deliberately flattens proto `Content` oneof variants into a
//! small renderable vocabulary so the component never touches wire types.
//!
//! The component is intentionally **data-source-agnostic**: it owns only the
//! ordered `lines` and view state, and reacts to `Action`s. The same struct
//! drives the live cockpit (history backfill + live stream merged by the loop)
//! and a standalone local debug view that feeds it canned `TranscriptHistory`
//! actions — no code path reaches the network from here.

use color_eyre::Result;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
    Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};

use super::Component;
use crate::action::Action;

/// The kind of a rendered transcript line — a flattened, render-friendly
/// projection of the proto `content::Type` oneof (plus stream lifecycle).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LineKind {
    /// User input text.
    User,
    /// Assistant/model prose (role "assistant" or "model").
    Assistant,
    /// A model "thinking" summary line (from `ThoughtContent.summary`).
    Thought,
    /// A tool result. `call_id` is the originating tool-call id
    /// (`ToolResultContent.call_id`).
    ToolResult {
        /// The id of the call this result answers (`ToolResultContent.call_id`).
        call_id: String,
    },
    /// A system / status line (errors, turn boundaries).
    System,
}

/// One rendered line in the transcript view.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MessageLine {
    /// What kind of content this line represents.
    pub kind: LineKind,
    /// The display text (already stringified — args/results pre-formatted).
    pub text: String,
}

/// The transcript component.
pub(crate) struct Transcript {
    /// The conversation currently rendered, if any.
    pub conversation_id: Option<String>,
    /// Rendered lines, oldest first.
    pub lines: Vec<MessageLine>,
    /// Whether a turn is actively streaming into `lines`.
    pub streaming: bool,
    /// Whether `Thought` lines are collapsed (default) or shown in full.
    pub thoughts_collapsed: bool,
    /// Vertical scroll offset, in rendered rows. `None` == follow the tail.
    scroll: Option<u16>,
    /// Last computed total rendered-row count (for scroll clamping/scrollbar).
    total_rows: u16,
    /// Last computed inner viewport height (rows), for paging math.
    viewport_rows: u16,
}

impl Default for Transcript {
    fn default() -> Self {
        Self {
            conversation_id: None,
            lines: Vec::new(),
            streaming: false,
            thoughts_collapsed: true,
            scroll: None,
            total_rows: 0,
            viewport_rows: 0,
        }
    }
}

impl Transcript {
    /// Switch the rendered conversation, clearing prior lines and view state.
    fn select(&mut self, id: &str) {
        if self.conversation_id.as_deref() == Some(id) {
            return;
        }
        self.conversation_id = Some(id.to_owned());
        self.lines.clear();
        self.streaming = false;
        self.scroll = None;
    }

    /// Whether an incoming action for `conversation_id` targets the conversation
    /// we are currently rendering. A delta for a different conversation is
    /// ignored (the loop fans out to every component).
    fn targets_current(&self, conversation_id: &str) -> bool {
        self.conversation_id.as_deref() == Some(conversation_id)
    }

    /// Toggle the collapsed/expanded state of `Thought` lines.
    const fn toggle_thoughts(&mut self) {
        self.thoughts_collapsed = !self.thoughts_collapsed;
    }

    /// Scroll up by `n` rows, leaving follow mode.
    fn scroll_up(&mut self, n: u16) {
        let cur = self.scroll.unwrap_or_else(|| self.max_scroll());
        self.scroll = Some(cur.saturating_sub(n));
    }

    /// Scroll down by `n` rows; reaching the bottom re-enters follow mode.
    fn scroll_down(&mut self, n: u16) {
        let cur = self.scroll.unwrap_or_else(|| self.max_scroll());
        let next = cur.saturating_add(n);
        if next >= self.max_scroll() {
            self.scroll = None; // snap back to following the tail
        } else {
            self.scroll = Some(next);
        }
    }

    /// The largest valid scroll offset given the last layout.
    const fn max_scroll(&self) -> u16 {
        self.total_rows.saturating_sub(self.viewport_rows)
    }

    /// Build the styled, wrapped lines for the current `lines`, honouring the
    /// thought-collapse setting. Pure: depends only on owned state.
    fn render_lines(&self) -> Vec<Line<'static>> {
        let mut out: Vec<Line<'static>> = Vec::with_capacity(self.lines.len());
        for ml in &self.lines {
            match &ml.kind {
                LineKind::User => out.push(prefixed(
                    "you",
                    &ml.text,
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                    Style::default().fg(Color::White),
                )),
                LineKind::Assistant => out.push(prefixed(
                    "agent",
                    &ml.text,
                    Style::default()
                        .fg(Color::Green)
                        .add_modifier(Modifier::BOLD),
                    Style::default(),
                )),
                LineKind::Thought => {
                    if self.thoughts_collapsed {
                        let preview = first_line(&ml.text);
                        out.push(Line::from(vec![
                            Span::styled(
                                "thought ▸ ",
                                Style::default()
                                    .fg(Color::Magenta)
                                    .add_modifier(Modifier::DIM | Modifier::ITALIC),
                            ),
                            Span::styled(
                                preview,
                                Style::default()
                                    .fg(Color::Magenta)
                                    .add_modifier(Modifier::DIM | Modifier::ITALIC),
                            ),
                        ]));
                    } else {
                        out.push(prefixed(
                            "thought ▾",
                            &ml.text,
                            Style::default()
                                .fg(Color::Magenta)
                                .add_modifier(Modifier::ITALIC),
                            Style::default()
                                .fg(Color::Magenta)
                                .add_modifier(Modifier::ITALIC),
                        ));
                    }
                }
                LineKind::ToolResult { call_id } => {
                    let head = if call_id.is_empty() {
                        "← result".to_owned()
                    } else {
                        format!("← result ({call_id})")
                    };
                    out.push(prefixed(
                        &head,
                        &ml.text,
                        Style::default()
                            .fg(Color::Yellow)
                            .add_modifier(Modifier::DIM),
                        Style::default().fg(Color::Gray),
                    ));
                }
                LineKind::System => out.push(Line::from(Span::styled(
                    ml.text.clone(),
                    Style::default()
                        .fg(Color::DarkGray)
                        .add_modifier(Modifier::ITALIC),
                ))),
            }
        }
        out
    }
}

/// Build a `prefix: text` styled line.
fn prefixed(prefix: &str, text: &str, prefix_style: Style, text_style: Style) -> Line<'static> {
    Line::from(vec![
        Span::styled(format!("{prefix}: "), prefix_style),
        Span::styled(text.to_owned(), text_style),
    ])
}

/// First line of a multi-line string (for collapsed thoughts).
fn first_line(text: &str) -> String {
    text.lines().next().unwrap_or("").to_owned()
}

impl Component for Transcript {
    fn controls(&self) -> Vec<(&'static str, &'static str)> {
        vec![
            ("↑↓/jk", "scroll"),
            ("PgUp/PgDn", "page"),
            ("t", "thoughts"),
        ]
    }

    fn handle(&mut self, action: &Action) -> Option<Action> {
        match action {
            Action::Select(id) => {
                self.select(id);
            }
            Action::TranscriptHistory {
                conversation_id,
                lines,
            } => {
                // Adopt history for the (possibly newly) selected conversation.
                // History always replaces — it is the authoritative backfill —
                // and any live deltas that already merged in are re-established
                // by the subsequent stream.
                self.select(conversation_id);
                if self.targets_current(conversation_id) {
                    self.lines.clone_from(lines);
                    self.scroll = None;
                }
            }
            Action::Key(key) => {
                use ratatui::crossterm::event::KeyCode;
                match key.code {
                    KeyCode::Char('t') => self.toggle_thoughts(),
                    KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1),
                    KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1),
                    KeyCode::PageUp => self.scroll_up(self.viewport_rows.max(1)),
                    KeyCode::PageDown => self.scroll_down(self.viewport_rows.max(1)),
                    KeyCode::Home => self.scroll = Some(0),
                    KeyCode::End => self.scroll = None,
                    _ => {}
                }
            }
            _ => {}
        }
        None
    }

    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
        let id_label = self.conversation_id.as_deref().unwrap_or("(none)");
        let status = if self.streaming { " ● streaming" } else { "" };
        let title = format!(" transcript: {id_label}{status} ");

        let block = Block::default().title(title).borders(Borders::ALL);
        let inner = block.inner(area);
        frame.render_widget(block, area);

        if self.lines.is_empty() {
            let hint = if self.conversation_id.is_some() {
                "no messages yet"
            } else {
                "select a conversation"
            };
            let widget = Paragraph::new(Span::styled(
                hint,
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::ITALIC),
            ));
            frame.render_widget(widget, inner);
            self.total_rows = 0;
            self.viewport_rows = inner.height;
            return Ok(());
        }

        // Reserve a 1-col gutter on the right for the scrollbar.
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Min(1), Constraint::Length(1)])
            .split(inner);
        let text_area = cols[0];
        let bar_area = cols[1];

        let lines = self.render_lines();
        // Lines in a single transcript never approach u16::MAX; a saturating
        // cast keeps the scroll math correct even in the impossible overflow.
        let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);

        // ratatui wraps for display; we approximate total rendered rows as the
        // number of logical lines (good enough for scroll bounds — wrapping
        // only ever increases rows, and `Paragraph` clamps an over-scroll).
        self.total_rows = line_count;
        self.viewport_rows = text_area.height;

        let max_scroll = self.max_scroll();
        let offset = self.scroll.map_or(max_scroll, |s| s.min(max_scroll));

        let para = Paragraph::new(lines)
            .wrap(Wrap { trim: false })
            .scroll((offset, 0));
        frame.render_widget(para, text_area);

        // Scrollbar reflects offset within the full logical-line range.
        let mut sb_state = ScrollbarState::new(self.total_rows as usize)
            .viewport_content_length(self.viewport_rows as usize)
            .position(offset as usize);
        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
            .begin_symbol(None)
            .end_symbol(None);
        frame.render_stateful_widget(scrollbar, bar_area, &mut sb_state);

        Ok(())
    }
}