Skip to main content

csd/detect/
mod.rs

1//! The hybrid state detector. No single source is sufficient (PoC §3): the transcript is reliable
2//! for questions/turns but lags TUI-interrupt gates; capture-pane catches the gates but its markers
3//! shift per release; the plan file holds plan content. [`detect`] combines all three.
4
5pub mod jsonl;
6pub mod pane;
7pub mod plan;
8
9use std::path::PathBuf;
10
11use serde::Serialize;
12
13use crate::backend;
14use crate::error::Result;
15use crate::session::Session;
16use crate::tmux;
17
18/// The externally-visible session state, serialized under a `status` tag for JSON consumers.
19#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
20#[serde(tag = "status", rename_all = "snake_case")]
21pub enum State {
22    /// tmux session is up but the TUI hasn't produced a classifiable turn yet.
23    Spawning,
24    /// Assistant is streaming or a tool call is in flight (`tools` empty if just streaming).
25    Working { tools: Vec<String> },
26    /// Assistant asked a clarifying question and is waiting for an answer (PoC §3.2).
27    AwaitingAnswer { question: String },
28    /// A plan is ready and the approval gate is on screen (PoC §3.3).
29    PlanReady {
30        plan_file: Option<PathBuf>,
31        plan: Option<String>,
32    },
33    /// A TUI gate is on screen (`gate` = "trust" | "permission") — answer it with `csd approve`.
34    Blocked {
35        gate: String,
36        prompt: String,
37        options: Vec<String>,
38    },
39    /// Assistant finished its turn cleanly and is waiting for the human.
40    IdleDone { text: String },
41    /// The tmux session is gone.
42    Dead,
43    /// Session is up but produced output we don't recognize.
44    Unknown,
45}
46
47/// Combine all three signals into a single [`State`] (precedence: capture-pane gates → transcript).
48pub fn detect(session: &Session) -> Result<State> {
49    if !tmux::has_session(&session.name)? {
50        return Ok(State::Dead);
51    }
52
53    let backend = backend::resolve(&session.backend)?;
54    let pane = tmux::capture_pane(&session.name)?;
55
56    // capture-pane wins for TUI-interrupt gates: these don't reach the transcript until answered.
57    if pane::contains_any(&pane, backend.plan_markers()) {
58        let (plan_file, plan) = match plan::latest_plan_file(session.created)? {
59            Some((path, content)) => (Some(path), content),
60            None => (None, None),
61        };
62        return Ok(State::PlanReady { plan_file, plan });
63    }
64    // The one-time folder-trust gate blocks the session at startup before any input is accepted.
65    if pane::contains_any(&pane, backend.trust_markers()) {
66        return Ok(State::Blocked {
67            gate: "trust".to_string(),
68            prompt: first_marker(&pane, backend.trust_markers()),
69            options: pane::parse_menu_options(&pane),
70        });
71    }
72    if pane::contains_any(&pane, backend.permission_markers()) {
73        return Ok(State::Blocked {
74            gate: "permission".to_string(),
75            prompt: first_marker(&pane, backend.permission_markers()),
76            options: pane::parse_menu_options(&pane),
77        });
78    }
79
80    // Otherwise trust the transcript.
81    let events = if session.jsonl_path.exists() {
82        jsonl::load(&session.jsonl_path)?
83    } else {
84        Vec::new()
85    };
86    Ok(map_jsonl(jsonl::classify(&events), session))
87}
88
89fn map_jsonl(state: jsonl::JsonlState, session: &Session) -> State {
90    use jsonl::JsonlState;
91    match state {
92        JsonlState::AwaitingAnswer { question } => State::AwaitingAnswer { question },
93        JsonlState::PlanReady { plan } => {
94            let plan_file = plan::latest_plan_file(session.created)
95                .ok()
96                .flatten()
97                .map(|(path, _)| path);
98            State::PlanReady { plan_file, plan }
99        }
100        JsonlState::ToolPending { tools } => State::Working { tools },
101        JsonlState::IdleDone { text } => State::IdleDone { text },
102        JsonlState::Working => State::Working { tools: Vec::new() },
103        // No assistant turn and no prompt yet → the session is up but hasn't produced a turn.
104        JsonlState::Unknown => State::Spawning,
105    }
106}
107
108/// The first permission marker present in the pane, as the human-readable prompt for `Blocked`.
109fn first_marker(pane: &str, markers: &[&str]) -> String {
110    markers
111        .iter()
112        .find(|m| pane.contains(**m))
113        .map(|m| m.to_string())
114        .unwrap_or_default()
115}