opencrabs 0.3.54

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! Split pane rendering — draws pane borders, labels, and delegates chat rendering.

use super::utils::wrap_line_with_padding;
use crate::tui::app::{App, DisplayMessage};
use crate::tui::pane::PaneId;
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Padding, Paragraph},
};

/// Render a single inactive (non-focused) pane.
/// Shows the session's cached messages as a read-only chat view.
pub(super) fn render_inactive_pane(f: &mut Frame, app: &App, pane_id: PaneId, area: Rect) {
    let pane = match app.pane_manager.get(pane_id) {
        Some(p) => p,
        None => return,
    };

    let session_label = pane
        .session_id
        .and_then(|sid| {
            app.sessions.iter().find(|s| s.id == sid).map(|s| {
                s.title
                    .clone()
                    .unwrap_or_else(|| format!("Session {}", &s.id.to_string()[..8]))
            })
        })
        .unwrap_or_else(|| "No session".to_string());

    let is_processing = pane
        .session_id
        .map(|sid| app.processing_sessions.contains(&sid))
        .unwrap_or(false);

    let status = if is_processing {
        " [processing...]"
    } else {
        ""
    };

    // Background live state for this session — populated by the
    // routing helper from any TuiEvent that arrived while this
    // session wasn't the focused pane. Drives the live tool /
    // thinking / streaming preview rows appended below the
    // cached message snapshot. `None` when the session has no
    // sidecar entry (either it's idle or never had a turn while
    // off-screen).
    let live = pane
        .session_id
        .and_then(|sid| app.background_sessions.get(&sid));

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray))
        .title(Span::styled(
            format!(" {}{} ", session_label, status),
            Style::default()
                .fg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        ))
        .padding(Padding::horizontal(1));

    let inner = block.inner(area);
    f.render_widget(block, area);

    if inner.height == 0 || inner.width == 0 {
        return;
    }

    // Render cached messages if available
    let cached = pane
        .session_id
        .and_then(|sid| app.pane_message_cache.get(&sid));

    let mut lines: Vec<Line<'static>> = Vec::new();

    if let Some(messages) = cached {
        for msg in messages {
            render_simple_message(&mut lines, msg, inner.width as usize);
        }
    }

    // Pending-message delta — flushed assistant text, tool-group
    // bullets, and queued user messages that arrived while this
    // session was off-screen. Rendered with the same simplified
    // shape as cached messages so the user sees a coherent feed
    // even when the inactive pane has accumulated several rounds.
    if let Some(bg) = live {
        for msg in &bg.pending_messages {
            render_simple_message(&mut lines, msg, inner.width as usize);
        }
    }

    // Live in-flight rows. These show what's happening in this
    // session RIGHT NOW even though it isn't the focused pane —
    // before this code existed the inactive pane was frozen at
    // whatever was last DB-loaded and the user saw stale state
    // until they tabbed back.
    if let Some(bg) = live {
        if let Some(ref group) = bg.active_tool_group {
            let n = group.calls.len();
            let all_done = group.calls.iter().all(|c| c.completed);
            let any_failed = group.calls.iter().any(|c| c.completed && !c.success);
            let (icon, color) = if !all_done {
                ("", Color::Yellow)
            } else if any_failed {
                ("", Color::Red)
            } else {
                ("", Color::DarkGray)
            };
            lines.push(Line::from(Span::styled(
                format!(
                    "  {} {} tool call{}",
                    icon,
                    n,
                    if n == 1 { "" } else { "s" }
                ),
                Style::default().fg(color),
            )));
        }
        if bg.streaming_reasoning.is_some() {
            lines.push(Line::from(Span::styled(
                "  ▸ Thinking",
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::ITALIC),
            )));
        }
        if let Some(ref text) = bg.streaming_response {
            let trimmed = text.trim();
            if !trimmed.is_empty() {
                // Show a single-line preview of the in-flight
                // streaming response. The active pane gets the
                // full rendered chat; here we only signal "text is
                // streaming in this session" so the user knows
                // there's progress to tab back to.
                let preview: String = trimmed
                    .lines()
                    .next()
                    .unwrap_or("")
                    .chars()
                    .take(120)
                    .collect();
                lines.push(Line::from(Span::styled(
                    preview,
                    Style::default().fg(Color::Reset),
                )));
            }
        }
    }

    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "Tab to switch focus",
            Style::default().fg(Color::DarkGray),
        )));
    }

    // Show last N lines that fit — no wrapping, so 1 Line = 1 row (guaranteed).
    let visible = inner.height as usize;
    let skip = lines.len().saturating_sub(visible);
    let visible_lines: Vec<Line> = lines.into_iter().skip(skip).collect();
    let para = Paragraph::new(visible_lines);
    f.render_widget(para, inner);
}

/// Render a single message in simplified form for inactive panes.
fn render_simple_message(lines: &mut Vec<Line<'static>>, msg: &DisplayMessage, width: usize) {
    // Skip system messages
    if msg.role == "system" || msg.role == "history_marker" {
        return;
    }

    // Tool groups: single collapsed line matching focused pane style
    if msg.role == "tool_group" {
        if let Some(ref group) = msg.tool_group {
            let n = group.calls.len();
            let all_done = group.calls.iter().all(|c| c.completed);
            let any_failed = group.calls.iter().any(|c| c.completed && !c.success);
            let (icon, color) = if !all_done {
                ("", Color::Yellow)
            } else if any_failed {
                ("", Color::Red)
            } else {
                ("", Color::DarkGray)
            };
            lines.push(Line::from(Span::styled(
                format!(
                    "  {} {} tool call{}",
                    icon,
                    n,
                    if n == 1 { "" } else { "s" }
                ),
                Style::default().fg(color),
            )));
        }
        return;
    }

    let is_assistant = msg.role == "assistant";

    // Strip reasoning/tool-marker blocks from content
    let mut content = msg.content.clone();
    // Remove <!-- reasoning -->...<!-- /reasoning --> blocks
    while let Some(start) = content.find("<!-- reasoning -->") {
        if let Some(end) = content.find("<!-- /reasoning -->") {
            content = format!(
                "{}{}",
                &content[..start],
                &content[end + "<!-- /reasoning -->".len()..]
            );
        } else {
            content = content[..start].to_string();
        }
    }
    // Remove <!-- tools-v2: [JSON] --> markers. Delegate to the shared
    // stripper so rustc `--> src/foo.rs:10` arrows embedded in tool
    // output don't truncate the match early and leak JSON into the
    // preview (see strip_html_comments doc for screenshot reference).
    if content.contains("<!-- tools-v2:") {
        content = crate::brain::agent::AgentService::strip_html_comments(&content);
    }
    let content = content.trim().to_string();

    // Show thinking indicator if assistant has reasoning details
    if is_assistant && msg.details.is_some() {
        lines.push(Line::from(Span::styled(
            "  ▸ Thinking",
            Style::default()
                .fg(Color::DarkGray)
                .add_modifier(Modifier::ITALIC),
        )));
    }

    // Truncate long messages for preview
    let content = if content.len() > 500 {
        let end = content.floor_char_boundary(497);
        format!("{}...", &content[..end])
    } else {
        content
    };

    if content.is_empty() {
        return;
    }

    // User input is shown verbatim (wrapped) so literal `*` or `_` chars in a
    // prompt aren't mangled by the markdown parser. Assistant (and any other)
    // content goes through the same markdown pipeline as the focused pane, then
    // gets wrapped to the pane width. Without this the inactive pane dumped raw
    // text: `**bold**` markers leaked through as literal asterisks and long
    // lines were hard-truncated at the border instead of wrapping (#180).
    if msg.role == "user" {
        for (i, raw) in content.lines().enumerate() {
            let prefix = if i == 0 { "> " } else { "" };
            let line = Line::from(Span::styled(
                format!("{}{}", prefix, raw),
                Style::default().fg(Color::Cyan),
            ));
            for wrapped in wrap_line_with_padding(line, width, "  ") {
                lines.push(wrapped);
            }
        }
    } else {
        for line in crate::tui::markdown::parse_markdown(&content, width) {
            for wrapped in wrap_line_with_padding(line, width, "") {
                lines.push(wrapped);
            }
        }
    }
    lines.push(Line::from(""));
}

/// Render the focused pane's border decoration.
/// Returns the inner area (content area inside the border) for the caller to render chat into.
pub(super) fn focused_pane_border(f: &mut Frame, app: &App, area: Rect) -> Rect {
    let pane = match app.pane_manager.focused_pane() {
        Some(p) => p,
        None => return area,
    };

    let session_label = pane
        .session_id
        .and_then(|sid| {
            app.sessions.iter().find(|s| s.id == sid).map(|s| {
                s.title
                    .clone()
                    .unwrap_or_else(|| format!("Session {}", &s.id.to_string()[..8]))
            })
        })
        .unwrap_or_else(|| "No session".to_string());

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Rgb(80, 200, 120)))
        .title(Span::styled(
            format!(" {} ", session_label),
            Style::default()
                .fg(Color::Rgb(80, 200, 120))
                .add_modifier(Modifier::BOLD),
        ))
        .padding(Padding::horizontal(0));

    let inner = block.inner(area);
    f.render_widget(block, area);
    inner
}