lemurclaw-tui 0.0.1

Terminal UI for the lemurclaw AI coding agent
//! Render persisted thread turns into history-cell building blocks.

use std::sync::Arc;

use crate::tui_internal::app_server_session::AppServerSession;
use crate::tui_internal::git_action_directives::parse_assistant_markdown;
use crate::tui_internal::history_cell::AgentMarkdownCell;
use crate::tui_internal::history_cell::HistoryCell;
use crate::tui_internal::history_cell::PlainHistoryCell;
use crate::tui_internal::history_cell::ReasoningSummaryCell;
use crate::tui_internal::history_cell::UserHistoryCell;
use crate::tui_internal::history_cell::split_reasoning_summary_parts;
use crate::tui_internal::inline_visualization::InlineVisualizationContext;
use crate::tui_internal::multi_agents::sub_agent_activity_summary;
use lemurclaw_core::app_server_protocol::Thread;
use lemurclaw_core::app_server_protocol::ThreadItem;
use lemurclaw_core::app_server_protocol::UserInput;
use lemurclaw_core::protocol::ThreadId;
use lemurclaw_core::protocol::items::UserMessageItem;
use ratatui::style::Stylize as _;
use ratatui::text::Line;

pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawReasoningVisibility {
    Hidden,
    Visible,
}

pub(crate) async fn load_session_transcript(
    app_server: &mut AppServerSession,
    thread_id: ThreadId,
    raw_reasoning_visibility: RawReasoningVisibility,
    codex_home: Option<&std::path::Path>,
) -> std::io::Result<TranscriptCells> {
    let thread = app_server
        .thread_read(thread_id, /*include_turns*/ true)
        .await
        .map_err(std::io::Error::other)?;
    Ok(thread_to_transcript_cells(
        thread,
        raw_reasoning_visibility,
        codex_home,
    ))
}

pub(crate) fn thread_to_transcript_cells(
    thread: Thread,
    raw_reasoning_visibility: RawReasoningVisibility,
    codex_home: Option<&std::path::Path>,
) -> TranscriptCells {
    let cwd = thread.cwd;
    let inline_visualization_context = codex_home.and_then(|codex_home| {
        ThreadId::from_string(&thread.id)
            .ok()
            .and_then(|thread_id| InlineVisualizationContext::new(codex_home, thread_id))
    });
    let mut cells: TranscriptCells = Vec::new();
    for item in thread.turns.into_iter().flat_map(|turn| turn.items) {
        match item {
            ThreadItem::UserMessage {
                id,
                client_id,
                content,
            } => {
                if content.iter().any(|input| {
                    matches!(
                        input,
                        UserInput::Audio { .. } | UserInput::LocalAudio { .. }
                    )
                }) {
                    tracing::warn!(
                        user_message_id = id,
                        "audio user inputs are not supported by the TUI and will be omitted"
                    );
                }
                let item = UserMessageItem {
                    id,
                    client_id,
                    content: content
                        .into_iter()
                        .map(lemurclaw_core::app_server_protocol::UserInput::into_core)
                        .collect(),
                };
                cells.push(Arc::new(UserHistoryCell {
                    message: item.message(),
                    text_elements: item.text_elements(),
                    local_image_paths: item.local_image_paths(),
                    remote_image_urls: item.image_urls(),
                }));
            }
            ThreadItem::AgentMessage { text, .. } => {
                let parsed = parse_assistant_markdown(&text, cwd.as_path());
                if !parsed.visible_markdown.trim().is_empty() {
                    cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
                        parsed.visible_markdown,
                        cwd.as_path(),
                        inline_visualization_context.clone(),
                    )));
                }
            }
            ThreadItem::Plan { text, .. } => {
                if !text.trim().is_empty() {
                    cells.push(Arc::new(crate::tui_internal::history_cell::new_proposed_plan(
                        text,
                        cwd.as_path(),
                    )));
                }
            }
            ThreadItem::Reasoning {
                summary, content, ..
            } => {
                let (header, text) =
                    if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
                        && !content.is_empty()
                    {
                        ("Reasoning".to_string(), content.join("\n\n"))
                    } else {
                        split_reasoning_summary_parts(&summary)
                    };
                if !text.trim().is_empty() {
                    cells.push(Arc::new(ReasoningSummaryCell::new(
                        header,
                        text,
                        cwd.as_path(),
                        /*transcript_only*/ false,
                    )));
                }
            }
            other => {
                if let Some(cell) = fallback_transcript_cell(&other) {
                    cells.push(Arc::new(cell));
                }
            }
        }
    }
    if cells.is_empty() {
        cells.push(Arc::new(PlainHistoryCell::new(vec![
            "No transcript content available".italic().dim().into(),
        ])));
    }
    cells
}

fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
    let lines = match item {
        ThreadItem::HookPrompt { fragments, .. } => fragments
            .iter()
            .map(|fragment| {
                vec![
                    "hook prompt: ".dim(),
                    fragment.text.trim().to_string().into(),
                ]
                .into()
            })
            .collect::<Vec<_>>(),
        ThreadItem::CommandExecution {
            command,
            status,
            aggregated_output,
            exit_code,
            ..
        } => {
            let mut lines: Vec<Line<'static>> =
                vec![vec!["$ ".dim(), command.clone().into()].into()];
            lines.push(
                format!(
                    "status: {status:?}{}",
                    exit_code
                        .map(|code| format!(" · exit {code}"))
                        .unwrap_or_default()
                )
                .dim()
                .into(),
            );
            if let Some(output) = aggregated_output.as_deref()
                && !output.trim().is_empty()
            {
                lines.extend(
                    output
                        .lines()
                        .map(|line| vec!["  ".dim(), line.trim_end().to_string().dim()].into()),
                );
            }
            lines
        }
        ThreadItem::FileChange {
            changes, status, ..
        } => vec![
            format!("file changes: {status:?} · {} changes", changes.len())
                .dim()
                .into(),
        ],
        ThreadItem::McpToolCall {
            server,
            tool,
            status,
            ..
        } => vec![
            format!("mcp tool: {server}/{tool} · {status:?}")
                .dim()
                .into(),
        ],
        ThreadItem::DynamicToolCall {
            namespace,
            tool,
            status,
            ..
        } => {
            let name = namespace
                .as_ref()
                .map(|namespace| format!("{namespace}/{tool}"))
                .unwrap_or_else(|| tool.clone());
            vec![format!("tool: {name} · {status:?}").dim().into()]
        }
        ThreadItem::CollabAgentToolCall { tool, status, .. } => {
            vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
        }
        ThreadItem::SubAgentActivity {
            kind, agent_path, ..
        } => {
            vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
        }
        ThreadItem::WebSearch(item) => {
            vec![vec!["web search: ".dim(), item.query.clone().into()].into()]
        }
        ThreadItem::ImageView { path, .. } => {
            let path = path.render_for_ui();
            vec![format!("image: {path}").dim().into()]
        }
        ThreadItem::ImageGeneration(item) => {
            let saved = item
                .saved_path
                .as_ref()
                .map(|path| format!(" · {}", path.as_path().display()))
                .unwrap_or_default();
            vec![
                format!("image generation: {}{saved}", item.status)
                    .dim()
                    .into(),
            ]
        }
        ThreadItem::EnteredReviewMode { review, .. } => {
            vec![vec!["review started: ".dim(), review.clone().into()].into()]
        }
        ThreadItem::ExitedReviewMode { review, .. } => {
            vec![vec!["review finished: ".dim(), review.clone().into()].into()]
        }
        ThreadItem::ContextCompaction { .. } => {
            vec!["context compacted".dim().into()]
        }
        ThreadItem::UserMessage { .. }
        | ThreadItem::AgentMessage { .. }
        | ThreadItem::Plan { .. }
        | ThreadItem::Reasoning { .. }
        | ThreadItem::Sleep(_) => return None,
    };
    (!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
}