a3s 0.10.5

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use super::model::{
    truncate_chars, RemoteAttention, RemoteSnapshot, RemoteTarget, RemoteTargetKind,
    RemoteTargetState, SafeReplyExcerpt,
};

const MAX_REMOTE_REPLY_CHARS: usize = 3_500;
pub(in crate::api::code_web) const REMOTE_LIST_PAGE_SIZE: usize = 12;

pub(in crate::api::code_web) struct RenderedRemoteList {
    pub(in crate::api::code_web) text: String,
    pub(in crate::api::code_web) page: u16,
    pub(in crate::api::code_web) target_ids: Vec<super::model::RemoteTargetId>,
}

pub(in crate::api::code_web) fn render_help() -> String {
    "A3S 微信只读指令\n\
帮助:查看指令\n\
智能体 [页码]:分页列出远程可见目标\n\
选择 2:选择列表中的目标\n\
进度:查看已选目标状态\n\
会话 [页码]:分页列出 A3S 管理会话\n\
最近回复:读取最近回复(默认关闭)\n\
清除选择:清除当前选择\n\n\
当前阶段不接受 Shell、PID、进程信号、文件操作或永久授权。"
        .to_string()
}

pub(in crate::api::code_web) fn render_targets(
    snapshot: &RemoteSnapshot,
    page: u16,
) -> RenderedRemoteList {
    render_list(snapshot, None, "远程可见智能体", "智能体", page)
}

pub(in crate::api::code_web) fn render_sessions(
    snapshot: &RemoteSnapshot,
    page: u16,
) -> RenderedRemoteList {
    render_list(
        snapshot,
        Some(RemoteTargetKind::ManagedSession),
        "A3S 管理会话",
        "会话",
        page,
    )
}

fn render_list(
    snapshot: &RemoteSnapshot,
    filter: Option<RemoteTargetKind>,
    title: &str,
    command: &str,
    page: u16,
) -> RenderedRemoteList {
    let matching = snapshot
        .items
        .iter()
        .filter(|target| filter.is_none_or(|kind| target.kind == kind))
        .collect::<Vec<_>>();
    if matching.is_empty() {
        return RenderedRemoteList {
            text: format!("{title}\n当前没有可见目标。请稍后重试。"),
            page: 1,
            target_ids: Vec::new(),
        };
    }

    let total_pages_usize = matching.len().div_ceil(REMOTE_LIST_PAGE_SIZE);
    let total_pages = u16::try_from(total_pages_usize).unwrap_or(u16::MAX);
    if page == 0 || page > total_pages {
        return RenderedRemoteList {
            text: bound_reply(format!(
                "{title}(共 {} 个,共 {total_pages} 页)\n没有第 {page} 页。发送“{command} 1”从第一页开始。",
                matching.len()
            )),
            page,
            target_ids: Vec::new(),
        };
    }

    let offset = usize::from(page.saturating_sub(1)).saturating_mul(REMOTE_LIST_PAGE_SIZE);
    let visible = matching
        .iter()
        .skip(offset)
        .take(REMOTE_LIST_PAGE_SIZE)
        .copied()
        .collect::<Vec<_>>();
    let mut lines = vec![format!(
        "{title}(第 {page}/{total_pages} 页,共 {} 个)",
        matching.len()
    )];
    for (index, target) in visible.iter().enumerate() {
        let workspace = target
            .workspace_alias
            .as_deref()
            .map(|alias| format!(" · {alias}"))
            .unwrap_or_default();
        let observed = if target.kind == RemoteTargetKind::ObservedProcess {
            " · 只读,执行状态未知"
        } else {
            ""
        };
        lines.push(format!(
            "{}. [{}] {} · {}{}{}",
            index + 1,
            kind_label(target.kind),
            target.display_name,
            state_label(target.state),
            workspace,
            observed
        ));
    }
    let mut navigation = Vec::new();
    if page > 1 {
        navigation.push(format!("发送“{command} {}”查看上一页", page - 1));
    }
    if page < total_pages {
        navigation.push(format!("发送“{command} {}”查看下一页", page + 1));
    }
    if !navigation.is_empty() {
        lines.push(format!("翻页:{}", navigation.join("")));
    }
    lines.push("发送“选择 序号”后可查询进度。".to_string());
    RenderedRemoteList {
        text: bound_reply(lines.join("\n")),
        page,
        target_ids: visible.iter().map(|target| target.id.clone()).collect(),
    }
}

pub(in crate::api::code_web) fn render_progress(target: &RemoteTarget) -> String {
    let mut lines = vec![
        format!("{} [{}]", target.display_name, kind_label(target.kind)),
        format!("状态:{}", state_label(target.state)),
        format!("证据:{}", confidence_label(target)),
    ];
    if let Some(workspace) = &target.workspace_alias {
        lines.push(format!("工作区:{workspace}"));
    }
    if let Some(progress) = &target.progress {
        if let Some(goal) = &progress.goal_summary {
            lines.push(format!("任务:{goal}"));
        }
        if let Some(percent) = progress.percent {
            let steps = if progress.total_steps > 0 {
                format!(
                    "{}/{} 步)",
                    progress.completed_steps, progress.total_steps
                )
            } else {
                String::new()
            };
            lines.push(format!("进度:{percent}%{steps}"));
        }
        lines.push(format!(
            "队列:{} 个待处理{}",
            progress.pending_turns,
            if progress.active_turn {
                ",当前有任务执行中"
            } else {
                ""
            }
        ));
    }
    if target.attention == RemoteAttention::ActionRequired {
        lines.push("需要处理:本机存在等待输入或已暂停的工作。".to_string());
    } else if target.attention == RemoteAttention::Error {
        lines.push("需要处理:目标报告失败或错误,请在本机查看详情。".to_string());
    }
    if target.kind == RemoteTargetKind::ObservedProcess {
        lines.push("限制:仅检测到进程,无法确认任务内容,也不能远程控制。".to_string());
    }
    lines.push(format!("引用:{}", target.id.short_ref()));
    bound_reply(lines.join("\n"))
}

pub(in crate::api::code_web) fn render_latest_reply(reply: &SafeReplyExcerpt) -> String {
    let suffix = if reply.truncated {
        "\n(内容已截断)"
    } else {
        ""
    };
    bound_reply(format!("最近回复\n{}{}", reply.text, suffix))
}

fn kind_label(kind: RemoteTargetKind) -> &'static str {
    match kind {
        RemoteTargetKind::ManagedSession => "会话",
        RemoteTargetKind::CooperativeAgent => "协作",
        RemoteTargetKind::ObservedProcess => "进程",
    }
}

fn state_label(state: RemoteTargetState) -> &'static str {
    match state {
        RemoteTargetState::Planning => "规划中",
        RemoteTargetState::Working => "执行中",
        RemoteTargetState::WaitingApproval => "等待批准",
        RemoteTargetState::WaitingInput => "等待输入",
        RemoteTargetState::Queued => "排队中",
        RemoteTargetState::Paused => "已暂停",
        RemoteTargetState::Idle => "空闲",
        RemoteTargetState::Completed => "已完成",
        RemoteTargetState::Failed => "失败",
        RemoteTargetState::Cancelled => "已取消",
        RemoteTargetState::Detected => "已检测",
        RemoteTargetState::Unknown => "未知",
    }
}

fn confidence_label(target: &RemoteTarget) -> &'static str {
    use super::model::RemoteEvidenceConfidence;
    match target.confidence {
        RemoteEvidenceConfidence::Authoritative => "A3S 管理状态",
        RemoteEvidenceConfidence::Exact => "A3S 精确心跳",
        RemoteEvidenceConfidence::Process => "进程推断",
    }
}

fn bound_reply(value: String) -> String {
    truncate_chars(&value, MAX_REMOTE_REPLY_CHARS)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::code_web::remote::model::{
        RemoteCapability, RemoteEvidenceConfidence, RemoteProgress, RemoteSnapshot, RemoteTargetId,
    };

    #[test]
    fn renderer_labels_observed_targets_as_unknown_and_read_only() {
        let snapshot = RemoteSnapshot::new(
            10,
            vec![RemoteTarget::observed(
                "process:42",
                "codex".to_string(),
                Some("project".to_string()),
                10,
            )],
            Vec::new(),
        );
        let rendered = render_targets(&snapshot, 1);
        assert!(rendered.text.contains("只读,执行状态未知"));
        assert!(!rendered.text.contains("42"));
        assert_eq!(rendered.target_ids.len(), 1);
    }

    #[test]
    fn renderer_paginates_inventory_deterministically_with_page_local_references() {
        let snapshot = RemoteSnapshot::new(
            10,
            (0..25)
                .map(|index| {
                    RemoteTarget::observed(
                        &format!("process-{index}"),
                        format!("agent-{index:02}"),
                        None,
                        10,
                    )
                })
                .collect(),
            Vec::new(),
        );

        let second = render_targets(&snapshot, 2);
        assert!(second.text.contains("第 2/3 页"));
        assert!(second.text.contains("1. [进程] agent-12"));
        assert!(second.text.contains("12. [进程] agent-23"));
        assert!(!second.text.contains("agent-11"));
        assert!(!second.text.contains("agent-24"));
        assert!(second.text.contains("智能体 1"));
        assert!(second.text.contains("智能体 3"));
        assert_eq!(second.page, 2);
        assert_eq!(second.target_ids.len(), 12);

        let last = render_targets(&snapshot, 3);
        assert_eq!(last.target_ids.len(), 1);
        assert!(last.text.contains("1. [进程] agent-24"));

        let missing = render_targets(&snapshot, 4);
        assert!(missing.target_ids.is_empty());
        assert!(missing.text.contains("没有第 4 页"));
        assert!(missing.text.contains("共 3 页"));
    }

    #[test]
    fn progress_renderer_never_needs_raw_source_identifiers() {
        let target = RemoteTarget {
            id: RemoteTargetId::for_source(RemoteTargetKind::ManagedSession, "secret-session"),
            kind: RemoteTargetKind::ManagedSession,
            display_name: "Build panel".to_string(),
            workspace_alias: Some("web".to_string()),
            state: RemoteTargetState::Working,
            state_detail: "Working".to_string(),
            confidence: RemoteEvidenceConfidence::Authoritative,
            attention: RemoteAttention::None,
            evidence_at_ms: 10,
            parent_id: None,
            capabilities: vec![RemoteCapability::ReadStatus],
            progress: Some(RemoteProgress {
                goal_summary: Some("Finish tests".to_string()),
                percent: Some(50),
                completed_steps: 1,
                total_steps: 2,
                pending_turns: 0,
                active_turn: true,
            }),
        };
        let rendered = render_progress(&target);
        assert!(rendered.contains("进度:50%(1/2 步)"));
        assert!(!rendered.contains("secret-session"));
    }
}