collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use super::App;

use crate::agent::context::ConversationContext;
use crate::agent::session::SessionSnapshot;
use crate::tui::state::{ChatMessage, MessageRole};

use super::utils::truncate;

impl App {
    /// Check for an incomplete session and offer to resume it.
    ///
    /// Checks the session store first; falls back to the STATUS.md journal
    /// (written by `Journal`) if no snapshot is found.
    pub async fn try_resume_session(&mut self) {
        // Secondary fallback: check if the journal shows an incomplete session.
        if let Some(journal_id) =
            crate::agent::session::detect_incomplete_journal(&self.working_dir).await
        {
            tracing::debug!(session_id = %journal_id, "Detected incomplete session via journal");
        }

        if let Some(snapshot) = self.session_store.find_incomplete().await {
            let task_preview = snapshot.user_task.as_deref().unwrap_or("(unknown task)");
            let msg = format!(
                "Incomplete session detected: \"{}\"\n   Session: {}\n   Messages: {}\n   Type /resume to restore, or start a new conversation.",
                truncate(task_preview, 80),
                &snapshot.session_id[..8],
                snapshot.messages.len(),
            );
            self.state
                .messages
                .push(ChatMessage::text(MessageRole::System, msg));

            // Store snapshot for potential resume
            self.state.pending_resume = Some(snapshot);
        }
    }

    /// Force-resume a session immediately (called from --continue / --resume).
    /// Skips the try_resume_session prompt — goes straight into restored state.
    pub fn force_resume(&mut self, snapshot: SessionSnapshot) {
        self.resume_from_snapshot(snapshot);
    }

    /// Resume from a stored session snapshot.
    pub(super) fn resume_from_snapshot(&mut self, snapshot: SessionSnapshot) {
        let msg_count = snapshot.messages.len();
        let task_preview = snapshot.user_task.as_deref().unwrap_or("(unknown task)");
        let session_short = snapshot.session_id[..8.min(snapshot.session_id.len())].to_string();

        if let Some(ref model) = snapshot.model {
            self.state.model_name = model.clone();
            self.config.model = model.clone();
            self.client.model = model.clone();
            // Re-resolve CLI provider if the restored model belongs to a CLI entry.
            // config.cli is never persisted in the session file; restore it here.
            self.config.cli = None;
            self.config.cli_args = Vec::new();
            if let Ok(file) = crate::config::load_config_file()
                && let Some(entry) = file
                    .providers
                    .iter()
                    .find(|pe| pe.all_models().contains(&model.as_str()))
                && entry.is_cli()
            {
                self.config.cli = entry.cli.clone();
                self.config.cli_args = entry.cli_args.clone();
                self.state.provider_name = entry.name.clone();
            }
        }

        let context = ConversationContext::restore_with_budget(
            snapshot.system_prompt,
            snapshot.messages,
            snapshot.last_reasoning,
            self.config.context_max_tokens,
            self.config.compaction_threshold,
        );
        // Sync UI token budget display with restored context.
        self.state.context_used_tokens = context.used_tokens();
        self.state.context_max_tokens = context.max_context_tokens();
        self.state.compaction_count = context.compaction_count();
        self.context = Some(context);
        self.session_id = snapshot.session_id;

        // Restore full UI state from snapshot
        if let Some(ref ui) = snapshot.ui_state {
            use crate::tui::state::{
                ChangedFileEntry as CFE, ChatMessage as CM, TokenStats as TS, ToolLogEntry as TLE,
            };

            if let Some(val) = ui.get("messages").cloned() {
                match serde_json::from_value::<Vec<CM>>(val) {
                    Ok(msgs) => self.state.messages = msgs,
                    Err(e) => tracing::warn!("Session restore: failed to parse messages: {e}"),
                }
            }
            if let Some(val) = ui.get("tool_log").cloned() {
                match serde_json::from_value::<Vec<TLE>>(val) {
                    Ok(log) => self.state.tool_log = log,
                    Err(e) => tracing::warn!("Session restore: failed to parse tool_log: {e}"),
                }
            }
            if let Some(val) = ui.get("changed_files").cloned() {
                match serde_json::from_value::<Vec<CFE>>(val) {
                    Ok(files) => self.state.changed_files = files,
                    Err(e) => tracing::warn!("Session restore: failed to parse changed_files: {e}"),
                }
            }
            if let Some(v) = ui.get("iteration").and_then(|v| v.as_u64()) {
                self.state.iteration = v as u32;
            }
            if let Some(v) = ui.get("elapsed_secs").and_then(|v| v.as_u64()) {
                self.state.elapsed_secs = v;
            }
            if let Some(val) = ui.get("token_stats").cloned() {
                match serde_json::from_value::<TS>(val) {
                    Ok(ts) => self.state.token_stats = ts,
                    Err(e) => tracing::warn!("Session restore: failed to parse token_stats: {e}"),
                }
            }
            if let Some(v) = ui.get("compaction_count").and_then(|v| v.as_u64()) {
                self.state.compaction_count = v as usize;
            }
            if let Some(v) = ui.get("cache_hit_rate").and_then(|v| v.as_f64()) {
                self.state.cache_hit_rate = v;
            }
            if let Some(v) = ui.get("agent_mode").and_then(|v| v.as_str()) {
                self.state.agent_mode = v.to_string();
            }
            if let Some(val) = ui.get("input_history").cloned() {
                match serde_json::from_value::<Vec<String>>(val) {
                    Ok(history) => self.input_history = history,
                    Err(e) => tracing::warn!("Session restore: failed to parse input_history: {e}"),
                }
            }
        }

        self.state.messages.push(ChatMessage::text(
            MessageRole::System,
            format!(
                "Session restored ({session_short}, {msg_count} messages, {} iterations)\n   Task: \"{}\"\n   Continue from where you left off.",
                self.state.iteration,
                truncate(task_preview, 80),
            )
        ));

        tracing::info!(session_id = %self.session_id, "Session resumed with full UI state");
    }

    /// Build and open the session resume popup using pre-loaded snapshots.
    pub fn open_session_resume_popup(&mut self) {
        use crate::tui::state::{PopupKind, PopupState};

        if self.state.session_snapshots.is_empty() {
            self.state.messages.push(ChatMessage::text(
                MessageRole::System,
                "No saved sessions found.",
            ));
            return;
        }

        let items: Vec<(String, String, String, String)> = self
            .state
            .session_snapshots
            .iter()
            .map(|snap| {
                let short_id = snap.session_id.chars().take(8).collect::<String>();
                let ts = snap
                    .timestamp
                    .get(..16)
                    .unwrap_or(&snap.timestamp)
                    .to_string();
                let status = if snap.completed { "done   " } else { "active " }.to_string();
                let task = snap.user_task.as_deref().unwrap_or("(no task)").to_string();
                (short_id, ts, status, task)
            })
            .collect();

        self.state.popup = Some(PopupState {
            title: "Resume Session".to_string(),
            content: String::new(),
            scroll: 0,
            kind: PopupKind::SessionResume { items, selected: 0 },
            saved_theme: None,
            select_prefix: None,
            search: String::new(),
        });
    }

    /// Save current session state to disk.
    pub(super) async fn save_session(&self, completed: bool) {
        // Use primary context, or fall back to backup (kept while agent is running)
        let context = match self.context.as_ref() {
            Some(ctx) => ctx,
            None => match self.last_context_backup.as_ref() {
                Some(ctx) => ctx,
                None => {
                    tracing::warn!(
                        "Cannot save session: no context available (agent may have been interrupted)"
                    );
                    return;
                }
            },
        };

        let user_task = context
            .messages()
            .iter()
            .find(|m| m.role == "user")
            .and_then(|m| m.content.as_ref().map(|c| c.text_content()));

        // Keep last 10 prompts for history restore
        let history_tail: Vec<&String> = self
            .input_history
            .iter()
            .rev()
            .take(10)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect();
        let ui_state = serde_json::json!({
            "messages": self.state.messages,
            "tool_log": self.state.tool_log,
            "changed_files": self.state.changed_files,
            "iteration": self.state.iteration,
            "elapsed_secs": self.state.elapsed_secs,
            "token_stats": self.state.token_stats,
            "compaction_count": self.state.compaction_count,
            "cache_hit_rate": self.state.cache_hit_rate,
            "agent_mode": self.state.agent_mode,
            "input_history": history_tail,
        });

        let snapshot = SessionSnapshot {
            session_id: self.session_id.clone(),
            working_dir: self.working_dir.clone(),
            system_prompt: context.system_prompt().to_string(),
            messages: context.messages().to_vec(),
            last_reasoning: None,
            timestamp: chrono::Utc::now().to_rfc3339(),
            completed,
            user_task,
            model: Some(self.config.model.clone()),
            ui_state: Some(ui_state),
        };

        if let Err(e) = self.session_store.save(&snapshot).await {
            tracing::warn!("Failed to save session: {e}");
        } else {
            // Prune old completed sessions after each save to keep the session
            // directory from growing unboundedly (keep the last 50 completed).
            match self.session_store.cleanup(50).await {
                Ok(removed) if removed > 0 => {
                    tracing::debug!(removed, "Pruned old completed sessions");
                }
                Err(e) => tracing::warn!("Session cleanup failed: {e}"),
                _ => {}
            }
        }
    }
}