rho-coding-agent 1.24.0

A lightweight agent harness inspired by Pi
Documentation
use std::collections::HashMap;

use ratatui::DefaultTerminal;

use crate::{
    commands::CommandInvocation,
    session::{
        tree::NodeId,
        workspace_checkpoint::{
            RestoreAudit, RestoreClassification, RestorePlan, WorkspaceCheckpoint,
        },
    },
};

use super::{
    picker_overlay::OverlayChrome, App, ComposerMode, Entry, InteractiveRuntime, PickerAction,
    PickerBadge, PickerBadgeTone, PickerItem, PickerLayout, UiPicker,
};

impl App {
    pub(super) fn execute_rewind_command(
        &mut self,
        invocation: CommandInvocation,
        agent: &InteractiveRuntime,
    ) -> anyhow::Result<()> {
        if !agent.workspace_rewind_enabled() {
            self.insert_entry(&Entry::Notice(
                "workspace rewind is experimental; set behavior.experimental_workspace_rewind = true and restart Rho"
                    .into(),
            ));
            self.status = "workspace rewind disabled".into();
            return Ok(());
        }
        let checkpoints = agent.workspace_checkpoints()?;
        if checkpoints.is_empty() {
            self.insert_entry(&Entry::Notice(
                "this session has no workspace checkpoints".into(),
            ));
            self.status = "no workspace checkpoints".into();
            return Ok(());
        }
        if !invocation.args.is_empty() {
            let matching = checkpoints
                .iter()
                .filter(|checkpoint| checkpoint.node_id.as_str().starts_with(&invocation.args))
                .collect::<Vec<_>>();
            anyhow::ensure!(
                matching.len() == 1,
                "turn '{}' must match exactly one workspace checkpoint",
                invocation.args
            );
            return self.open_rewind_preview(matching[0].node_id.as_str(), agent);
        }

        let previews = agent
            .stored_session()
            .map(|storage| storage.tree_items())
            .transpose()?
            .unwrap_or_default()
            .into_iter()
            .filter_map(|item| {
                item.first_user_text
                    .map(|preview| (item.id.to_string(), preview))
            })
            .collect::<HashMap<_, _>>();
        let items = checkpoints
            .into_iter()
            .rev()
            .map(|checkpoint| {
                let id = checkpoint.node_id.to_string();
                let short_id = &id[..id.len().min(8)];
                let preview = previews
                    .get(&id)
                    .cloned()
                    .unwrap_or_else(|| format!("turn {short_id}"));
                PickerItem {
                    section: None,
                    label: preview,
                    detail: Some(format!(
                        "checkpoint time: {}\ntracked files: {}\noutcome: {:?}",
                        checkpoint.finalized_at,
                        checkpoint.files.len(),
                        checkpoint.outcome
                    )),
                    preview: None,
                    badge: (!checkpoint.limitations.is_empty()).then_some(PickerBadge {
                        text: "limited".into(),
                        tone: PickerBadgeTone::Warning,
                    }),
                    value: id,
                    selection_verb: None,
                }
            })
            .collect();
        let picker = UiPicker::new(
            "Workspace rewind",
            "select a completed turn to preview; no files change in this step",
            items,
            PickerAction::SelectRewindCheckpoint,
        )
        .with_layout(PickerLayout::Overlay)
        .with_overlay_chrome(OverlayChrome {
            nav_label: " REWIND".into(),
            detail_label: Some("CHECKPOINT".into()),
            nav_keys_hint: "↑↓ turns".into(),
        })
        .with_confirm_verb("preview");
        self.input_ui.set_composer(ComposerMode::Picker(picker));
        self.status = "select workspace checkpoint".into();
        Ok(())
    }

    pub(super) fn submit_rewind_preview(
        &mut self,
        value: &str,
        agent: &InteractiveRuntime,
    ) -> anyhow::Result<()> {
        self.open_rewind_preview(value, agent)
    }

    fn open_rewind_preview(
        &mut self,
        value: &str,
        agent: &InteractiveRuntime,
    ) -> anyhow::Result<()> {
        let node_id = NodeId::from_string(value)?;
        let (checkpoint, plan) = agent.preview_workspace_rewind(&node_id)?;
        let detail = rewind_preview_text(&checkpoint, &plan, agent.workspace_path());
        let blocked = plan.entries.iter().any(|entry| {
            matches!(
                entry.classification,
                RestoreClassification::Conflict | RestoreClassification::Unsupported
            )
        });
        let item = PickerItem {
            section: None,
            label: if blocked {
                "Confirm safe paths; conflicts stay unchanged".into()
            } else {
                "Confirm workspace rewind".into()
            },
            detail: Some(detail),
            preview: None,
            badge: blocked.then_some(PickerBadge {
                text: "partial".into(),
                tone: PickerBadgeTone::Warning,
            }),
            value: value.to_string(),
            selection_verb: None,
        };
        let picker = UiPicker::new(
            "Confirm workspace rewind",
            "review every path, then press enter to restore; esc cancels without writes",
            vec![item],
            PickerAction::ConfirmRewindCheckpoint,
        )
        .with_layout(PickerLayout::Overlay)
        .with_overlay_chrome(OverlayChrome {
            nav_label: " CONFIRM".into(),
            detail_label: Some("RESTORE PREVIEW".into()),
            nav_keys_hint: "esc cancels".into(),
        })
        .with_confirm_verb("rewind");
        self.input_ui.set_composer(ComposerMode::Picker(picker));
        self.status = "confirm workspace rewind".into();
        Ok(())
    }

    pub(super) async fn submit_rewind_confirmation(
        &mut self,
        value: &str,
        terminal: &mut DefaultTerminal,
        agent: &mut InteractiveRuntime,
    ) -> anyhow::Result<()> {
        let node_id = NodeId::from_string(value)?;
        let audit = agent.restore_workspace_rewind(&node_id).await?;
        let incomplete = audit.entries.iter().any(|entry| {
            entry.error.is_some()
                || matches!(
                    entry.classification,
                    RestoreClassification::Conflict | RestoreClassification::Unsupported
                )
        });
        let selection_error = if incomplete {
            self.input_ui.set_composer(ComposerMode::Input);
            None
        } else {
            self.submit_tree_selection(value, terminal, agent)
                .await
                .err()
        };
        let conversation_selected = !incomplete && selection_error.is_none();
        self.insert_entry(&Entry::Notice(rewind_audit_text(
            &audit,
            agent.workspace_path(),
            conversation_selected,
        )));
        self.status = if selection_error.is_some() {
            "workspace restored; conversation selection failed".into()
        } else if incomplete {
            "workspace rewind completed with skipped paths".into()
        } else {
            "workspace rewind complete".into()
        };
        if let Some(error) = selection_error {
            return Err(error);
        }
        Ok(())
    }
}

fn rewind_preview_text(
    checkpoint: &WorkspaceCheckpoint,
    plan: &RestorePlan,
    workspace: &std::path::Path,
) -> String {
    let short_id = &checkpoint.node_id.as_str()[..checkpoint.node_id.as_str().len().min(8)];
    let mut lines = vec![
        format!("target turn: {short_id}"),
        format!("timestamp: {}", checkpoint.finalized_at),
        "conversation: select this state and preserve the existing branch".into(),
    ];
    for entry in &plan.entries {
        lines.push(format!(
            "{}  {}",
            classification_label(entry.classification),
            rho_tools::compact_display_path(workspace, &entry.path.to_string_lossy())
        ));
    }
    if plan.entries.is_empty() {
        lines.push("skip  no native file changes were captured".into());
    }
    for limitation in &plan.limitations {
        lines.push(format!(
            "warning  {} effects from '{}' cannot be restored",
            limitation_label(&limitation.kind),
            limitation.source
        ));
    }
    lines.join("\n")
}

fn rewind_audit_text(
    audit: &RestoreAudit,
    workspace: &std::path::Path,
    conversation_selected: bool,
) -> String {
    let mut lines = vec![if conversation_selected {
        "workspace rewind audit; conversation state selected".into()
    } else {
        "workspace rewind audit; conversation state was not selected".into()
    }];
    for entry in &audit.entries {
        let result =
            entry
                .error
                .as_deref()
                .unwrap_or(if entry.changed { "restored" } else { "skipped" });
        lines.push(format!(
            "{}  {}  {result}",
            classification_label(entry.classification),
            rho_tools::compact_display_path(workspace, &entry.path.to_string_lossy())
        ));
    }
    for limitation in &audit.limitations {
        lines.push(format!(
            "warning  {} effects from '{}' were not restored",
            limitation_label(&limitation.kind),
            limitation.source
        ));
    }
    lines.join("\n")
}

fn classification_label(classification: RestoreClassification) -> &'static str {
    match classification {
        RestoreClassification::Create => "create",
        RestoreClassification::Modify => "modify",
        RestoreClassification::Delete => "delete",
        RestoreClassification::Conflict => "conflict",
        RestoreClassification::Unsupported => "unsupported",
        RestoreClassification::Skipped => "skip",
    }
}

fn limitation_label(
    kind: &crate::session::workspace_checkpoint::UntrackedEffectKind,
) -> &'static str {
    match kind {
        crate::session::workspace_checkpoint::UntrackedEffectKind::ShellCommand => "shell",
        crate::session::workspace_checkpoint::UntrackedEffectKind::UntrackedMutatingTool => {
            "process"
        }
        crate::session::workspace_checkpoint::UntrackedEffectKind::ExternalEffect => "external",
    }
}