beam-worker 0.12.0

Per-session worker process for beam that owns terminal backends and CLI adapters
Documentation
use super::*;

const SCREEN_ANALYZER_SYSTEM_PROMPT: &str = r#"You analyze a terminal screen from an AI coding CLI.

Decide whether the CLI is waiting for user interaction, such as a menu choice, permission prompt, confirmation, text input, or multi-select. If the CLI is still working, printing output, or only showing status text, set needsInteraction=false.

Return only JSON:
{
  "needsInteraction": boolean,
  "description": string,
  "options": [{"label": string, "text": string, "selected": boolean}],
  "multiSelect": boolean,
  "toggleKey": string|null,
  "confirmKey": string|null,
  "checkAgainWhen": "content_changed"|"after_5s"|"after_10s"|"not_needed"
}"#;

#[derive(Debug, Clone, Default)]
pub(crate) struct AnalyzerRuntime {
    pub(crate) last_snapshot: String,
    pub(crate) stable_count: u32,
    pub(crate) last_analyzed_snapshot: String,
    pub(crate) waiting_for_content_change: bool,
    pub(crate) cooldown_until_ms: u64,
    pub(crate) is_analyzing: bool,
    pub(crate) prompt_active: bool,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AnalyzerChatResponse {
    choices: Vec<AnalyzerChoice>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AnalyzerChoice {
    message: AnalyzerMessage,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AnalyzerMessage {
    content: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AnalyzerParsedResponse {
    #[serde(rename = "needsInteraction")]
    needs_interaction: Option<bool>,
    description: Option<String>,
    options: Option<Vec<AnalyzerParsedOption>>,
    #[serde(rename = "multiSelect")]
    multi_select: Option<bool>,
    #[serde(rename = "toggleKey")]
    toggle_key: Option<String>,
    #[serde(rename = "confirmKey")]
    confirm_key: Option<String>,
    #[serde(rename = "checkAgainWhen")]
    check_again_when: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct AnalyzerParsedOption {
    label: Option<String>,
    text: Option<String>,
    selected: Option<bool>,
    #[serde(rename = "type")]
    option_type: Option<String>,
    index: Option<usize>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AnalyzerResult {
    pub(crate) needs_interaction: bool,
    pub(crate) description: Option<String>,
    pub(crate) options: Vec<TuiPromptOption>,
    pub(crate) multi_select: bool,
    pub(crate) check_again_when: String,
}

pub(crate) fn screen_analyzer_enabled(cfg: &ScreenAnalyzerConfig) -> bool {
    cfg.enabled
        && !cfg.base_url.trim().is_empty()
        && !cfg.api_key.trim().is_empty()
        && !cfg.model.trim().is_empty()
}

pub(crate) fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}

pub(crate) fn parse_screen_analyzer_response(content: &str) -> AnalyzerResult {
    let json_str = content
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    let parsed = serde_json::from_str::<AnalyzerParsedResponse>(json_str).ok();
    let check_again_when = parsed
        .as_ref()
        .and_then(|parsed| parsed.check_again_when.clone())
        .filter(|value| {
            matches!(
                value.as_str(),
                "content_changed" | "after_5s" | "after_10s" | "not_needed"
            )
        })
        .unwrap_or_else(|| "content_changed".to_string());
    let toggle_key = parsed
        .as_ref()
        .and_then(|parsed| parsed.toggle_key.clone())
        .unwrap_or_else(|| "Space".to_string());
    let confirm_key = parsed
        .as_ref()
        .and_then(|parsed| parsed.confirm_key.clone())
        .unwrap_or_else(|| "Enter".to_string());
    let options = parsed
        .as_ref()
        .and_then(|parsed| parsed.options.as_ref())
        .map(|options| build_tui_prompt_options(options, &toggle_key, &confirm_key))
        .unwrap_or_default();
    AnalyzerResult {
        needs_interaction: parsed
            .as_ref()
            .and_then(|parsed| parsed.needs_interaction)
            .unwrap_or(false),
        description: parsed
            .as_ref()
            .and_then(|parsed| parsed.description.clone()),
        options,
        multi_select: parsed
            .as_ref()
            .and_then(|parsed| parsed.multi_select)
            .unwrap_or(false),
        check_again_when,
    }
}

pub(crate) fn apply_screen_analyzer_result(
    runtime: &mut AnalyzerRuntime,
    check_again_when: &str,
    now_ms: u64,
) {
    match check_again_when {
        "after_5s" => {
            runtime.cooldown_until_ms = now_ms + 5_000;
            runtime.waiting_for_content_change = false;
        }
        "after_10s" => {
            runtime.cooldown_until_ms = now_ms + 10_000;
            runtime.waiting_for_content_change = false;
        }
        "not_needed" | "content_changed" => {
            runtime.waiting_for_content_change = true;
            runtime.cooldown_until_ms = 0;
        }
        _ => {
            runtime.waiting_for_content_change = true;
            runtime.cooldown_until_ms = 0;
        }
    }
}

pub(crate) fn build_tui_prompt_options(
    options: &[AnalyzerParsedOption],
    toggle_key: &str,
    confirm_key: &str,
) -> Vec<TuiPromptOption> {
    options
        .iter()
        .enumerate()
        .map(|(i, option)| {
            let index = option.index.unwrap_or(i);
            let option_type = option
                .option_type
                .clone()
                .filter(|kind| matches!(kind.as_str(), "select" | "toggle" | "confirm" | "input"))
                .unwrap_or_else(|| "select".to_string());
            let mut keys = Vec::new();
            for _ in 0..index {
                keys.push("Down".to_string());
            }
            match option_type.as_str() {
                "toggle" => {
                    keys.push(toggle_key.to_string());
                    for _ in 0..index {
                        keys.push("Up".to_string());
                    }
                }
                "select" | "confirm" | "input" => {
                    keys.push(confirm_key.to_string());
                }
                _ => {}
            }
            TuiPromptOption {
                label: option.label.clone().or_else(|| Some((i + 1).to_string())),
                text: option
                    .text
                    .clone()
                    .unwrap_or_default()
                    .replace('\n', " ")
                    .trim()
                    .to_string(),
                selected: option.selected.unwrap_or(false),
                option_type: Some(option_type),
                keys,
            }
        })
        .collect()
}

pub(crate) async fn call_screen_analyzer(
    client: &Client,
    cfg: &ScreenAnalyzerConfig,
    snapshot: &str,
) -> Result<AnalyzerResult> {
    let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
    let mut body = serde_json::json!({
        "model": cfg.model,
        "messages": [
            { "role": "system", "content": SCREEN_ANALYZER_SYSTEM_PROMPT },
            { "role": "user", "content": snapshot },
        ],
        "temperature": 0,
        "max_tokens": 2048,
    });
    if let Some(map) = body.as_object_mut() {
        for (key, value) in &cfg.extra_body {
            map.insert(key.clone(), value.clone());
        }
    }

    let mut headers = HeaderMap::new();
    headers.insert("content-type", "application/json".parse()?);
    headers.insert("authorization", format!("Bearer {}", cfg.api_key).parse()?);
    for (key, value) in &cfg.extra_headers {
        headers.insert(key.parse::<reqwest::header::HeaderName>()?, value.parse()?);
    }

    let response = client
        .post(url)
        .headers(headers)
        .json(&body)
        .timeout(Duration::from_secs(15))
        .send()
        .await?;
    let status = response.status();
    if !status.is_success() {
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!(
            "screen analyzer API {}: {}",
            status,
            text.chars().take(200).collect::<String>()
        );
    }
    let payload = response.json::<AnalyzerChatResponse>().await?;
    let content = payload
        .choices
        .first()
        .map(|choice| choice.message.content.clone())
        .unwrap_or_default();
    Ok(parse_screen_analyzer_response(&content))
}