holon 0.14.1

A headless, event-driven runtime for long-lived agents
Documentation
use std::path::{Path, PathBuf};

use crate::types::{AgentStatus, AgentSummary, WaitingReason, AGENT_HOME_WORKSPACE_ID};

use super::{
    keymap::{status_hint, KeyContext},
    overlay::OverlayState,
    TuiApp,
};

pub(super) struct HeaderViewModel {
    pub(super) line: String,
}

impl HeaderViewModel {
    pub(super) fn from_app(app: &TuiApp) -> Self {
        let line = app
            .selected_agent_summary()
            .map(render_header_line)
            .unwrap_or_else(|| "No agent selected.".to_string());
        Self { line }
    }
}

pub(super) struct StatusbarViewModel {
    pub(super) context_line: String,
    pub(super) status_line: String,
}

impl StatusbarViewModel {
    pub(super) fn from_app(app: &TuiApp, slash_visible: bool) -> Self {
        let context_line = format!("{} · {}", execution_root_summary(app), model_summary(app));
        let status_line = statusbar_detail(app, slash_visible);
        Self {
            context_line,
            status_line,
        }
    }
}

pub(super) fn render_header_line(agent: &AgentSummary) -> String {
    let mut line = format!("{}  {}", agent.identity.agent_id, agent_status_label(agent));
    if agent.lifecycle.resume_required {
        line.push_str(" · resume required");
    }
    line
}

fn agent_status_label(agent: &AgentSummary) -> &'static str {
    if agent.closure.waiting_reason == Some(WaitingReason::AwaitingOperatorInput) {
        return "waiting for you";
    }
    match agent.agent.status {
        AgentStatus::Booting => "booting",
        AgentStatus::AwakeIdle => "idle",
        AgentStatus::AwakeRunning => "running",
        AgentStatus::AwaitingTask => "waiting for task",
        AgentStatus::Asleep => "sleeping",
        AgentStatus::Stopped => "stopped",
    }
}

fn execution_root_summary(app: &TuiApp) -> String {
    let active_entry = app
        .projection
        .as_ref()
        .and_then(|projection| projection.workspace.active_workspace_entry.as_ref())
        .or_else(|| {
            app.selected_agent_summary()
                .and_then(|agent| agent.agent.active_workspace_entry.as_ref())
        });
    let Some(entry) = active_entry else {
        return "workspace not ready".into();
    };
    let label = workspace_label(
        entry.workspace_id.as_str(),
        entry.workspace_anchor.as_path(),
    );
    format!("{} ({})", label, shorten_home_path(&entry.execution_root))
}

fn workspace_label(workspace_id: &str, workspace_anchor: &Path) -> String {
    if workspace_id == AGENT_HOME_WORKSPACE_ID {
        return AGENT_HOME_WORKSPACE_ID.to_string();
    }
    workspace_anchor
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.trim().is_empty())
        .unwrap_or("workspace")
        .to_string()
}

fn shorten_home_path(path: &Path) -> String {
    let home = std::env::var_os("HOME").map(PathBuf::from);
    shorten_home_path_with_home(path, home.as_deref())
}

fn shorten_home_path_with_home(path: &Path, home: Option<&Path>) -> String {
    if let Some(home) = home {
        if let Ok(relative) = path.strip_prefix(home) {
            if relative.as_os_str().is_empty() {
                return "~".into();
            }
            return format!("~/{}", relative.display());
        }
    }
    path.display().to_string()
}

fn model_summary(app: &TuiApp) -> String {
    app.selected_agent_summary()
        .map(render_model_detail)
        .unwrap_or_else(|| "<no model>".into())
}

pub(super) fn render_model_detail(agent: &AgentSummary) -> String {
    let model = agent
        .model
        .active_model
        .as_ref()
        .unwrap_or(&agent.model.effective_model);
    if agent.model.fallback_active {
        let requested = agent
            .model
            .requested_model
            .as_ref()
            .unwrap_or(&agent.model.effective_model);
        return format!(
            "{} (fallback from {})",
            model.as_string(),
            requested.as_string()
        );
    }
    if agent.model.override_model.is_some() {
        if let Some(effort) = agent.model.override_reasoning_effort.as_deref() {
            return format!("{} (agent override, effort={})", model.as_string(), effort);
        }
        return format!("{} (agent override)", model.as_string());
    }
    model.as_string()
}

fn statusbar_detail(app: &TuiApp, slash_visible: bool) -> String {
    let status_line = app.status_line.trim();
    let detail = overlay_hint(app, slash_visible)
        .map(ToString::to_string)
        .or_else(|| (!status_line.is_empty()).then(|| status_line.to_string()))
        .or_else(|| app.connection_detail().map(ToString::to_string))
        .or_else(|| active_tasks_hint(app))
        .unwrap_or_else(|| "Type / for commands · /help for shortcuts".into());
    format!("{} · {}", app.connection_label(), detail)
}

fn overlay_hint(app: &TuiApp, slash_visible: bool) -> Option<&'static str> {
    if slash_visible {
        return Some(status_hint(KeyContext::SlashMenu, true));
    }
    let context = match &app.overlay {
        OverlayState::None => return None,
        OverlayState::Agents { .. } => KeyContext::AgentsOverlay,
        OverlayState::Events { .. } => KeyContext::EventsOverlay,
        OverlayState::Transcript { .. }
        | OverlayState::AgentState { .. }
        | OverlayState::DebugPromptView { .. }
        | OverlayState::HelpView { .. } => KeyContext::ScrollOverlay,
        OverlayState::Tasks { .. } => KeyContext::TasksOverlay,
        OverlayState::ModelPicker { .. } => KeyContext::ModelPicker,
        OverlayState::ModelEffortPicker { .. } => KeyContext::ModelEffortPicker,
        OverlayState::DebugPromptInput { .. } => KeyContext::DebugPromptInput,
    };
    Some(status_hint(context, false))
}

fn active_tasks_hint(app: &TuiApp) -> Option<String> {
    let count = app
        .projection
        .as_ref()
        .map(|projection| projection.tasks.len())
        .unwrap_or(0);
    match count {
        0 => None,
        1 => Some("1 active task · /tasks to inspect".into()),
        count => Some(format!("{count} active tasks · /tasks to inspect")),
    }
}

#[cfg(test)]
mod tests {
    use super::{shorten_home_path_with_home, workspace_label};
    use crate::types::AGENT_HOME_WORKSPACE_ID;
    use std::path::Path;

    #[test]
    fn workspace_label_uses_anchor_name_not_random_workspace_id() {
        assert_eq!(
            workspace_label(
                "ws-123456",
                Path::new("/Users/example/opensource/src/github.com/holon-run/holon")
            ),
            "holon"
        );
        assert_eq!(
            workspace_label(AGENT_HOME_WORKSPACE_ID, Path::new("/tmp/default")),
            "agent_home"
        );
    }

    #[test]
    fn shorten_home_path_uses_tilde_for_home_relative_paths() {
        assert_eq!(
            shorten_home_path_with_home(
                Path::new("/Users/example/opensource/holon"),
                Some(Path::new("/Users/example"))
            ),
            "~/opensource/holon"
        );
    }
}