procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
use crate::config::Provider;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;

/// The user's request to stop the turn in flight.
///
/// Deliberately not a `UserCommand`: while a turn runs, the agent task is inside the turn loop and
/// never polls `user_rx`, so a command sent to stop it would only be read once the thing it was
/// meant to stop had already finished. Shared memory is the one channel that reaches a task which
/// is busy by definition.
#[derive(Debug, Clone, Default)]
pub struct CancelFlag(Arc<AtomicBool>);

impl CancelFlag {
    pub fn raise(&self) {
        self.0.store(true, Ordering::SeqCst);
    }

    pub fn is_raised(&self) -> bool {
        self.0.load(Ordering::SeqCst)
    }

    /// Read and clear. The agent calls this so a cancel raised during one turn cannot leak into
    /// the next, which would kill a fresh prompt the instant it was sent.
    pub fn take(&self) -> bool {
        self.0.swap(false, Ordering::SeqCst)
    }

    /// Resolves once the flag is raised, for racing against work in flight.
    ///
    /// Polled rather than awaited on a notifier: the flag is plain shared memory precisely so the
    /// UI can raise it without the agent having to be listening, and 50ms is well under the point
    /// where Esc stops feeling instant.
    pub async fn wait(&self) {
        while !self.is_raised() {
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        }
    }
}

/// Third-party persona pack (Justin, Nicole, Kaan, Tyler, Elliot, Bri) — not authored or hosted by
/// this project. Named here, rather than only where it's fetched, so the UI's confirmation prompt
/// and the agent task's download both show the same URL by construction.
pub const STELLAR_BUILD_INSTALL_URL: &str =
    "https://raw.githubusercontent.com/kaankacar/stellar-build/main/install.sh";

#[derive(Debug)]
pub enum UserCommand {
    SendPrompt(String),
    Quit,
    SetExplain(bool),
    // Sent once project switching is wired to a command (Sprint 2.1).
    #[allow(dead_code)]
    ChangeProject(String),
    SwitchModel {
        provider: Provider,
        model: String,
    },
    /// Run one tool, now, because the user asked for it by name — a quick action or its slash
    /// command. Not a prompt: `Ctrl+T` is labelled "Run tests", and sending "run the tests" to the
    /// model made it a suggestion the model was free to answer with an opinion instead.
    RunTool {
        name: String,
        input: serde_json::Value,
        /// What to call it on screen while it runs.
        label: String,
    },
    /// Runs the third-party Stellar Build installer after the user has explicitly confirmed it.
    /// Routed through the agent task rather than handled in the UI thread: it downloads a script
    /// and runs it, which can take a while and must not freeze rendering or key handling.
    InstallStellarBuild,
}

/// The user's answer to a tool asking permission to act.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
    /// Run this one call.
    Once,
    /// Run this and every later call with the same `risk::scope` — the same tool acting on the
    /// same file, or the same contract on the same network — until the process exits. Scoped
    /// rather than keyed on the tool name because the question named a file, and answering it
    /// should not hand over every other file. Deliberately not persisted either: a permission
    /// granted in the middle of a turn is one nobody remembers granting, and it should not outlive
    /// the session it was granted in.
    Always,
    Deny,
}

/// One line of a conversation being put back on screen after `--resume`.
///
/// Rebuilt from the session log's events rather than from the message history the model gets:
/// only the events record whether a tool call failed, and a resumed transcript that showed every
/// call as fine would be a nicer story than the one on disk.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptEntry {
    User(String),
    Agent(String),
    Tool {
        name: String,
        ok: bool,
    },
    /// Where compaction replaced the head of the conversation.
    Compacted,
}

/// A tool call waiting on the user.
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
    pub tool: String,
    /// What the call would actually do, in the user's terms — the path it writes, the contract it
    /// invokes. A bare tool name is not enough to answer on.
    pub detail: String,
    /// What "always" would cover, from `risk::scope`. Carried so the transcript can say what was
    /// granted: "and write_file for the rest of this session" was a wider claim than the grant.
    pub scope: String,
}

#[derive(Debug, Clone)]
pub struct McpServerStatus {
    pub name: String,
    pub connected: bool,
    pub detail: String,
}

#[derive(Debug, Clone)]
pub struct WorkspaceSnapshot {
    pub project_name: String,
    pub contract_name: Option<String>,
    pub network: String,
    pub account: String,
    pub mcp_servers: Vec<McpServerStatus>,
    /// Whether signing on mainnet is actually permitted. Carried here rather than read by the UI
    /// so the screen and the gate answer from the same place.
    pub mainnet_allowed: bool,
}

#[allow(dead_code)]
#[derive(Debug)]
pub enum AgentUpdate {
    ResponseChunk(String),
    ResponseEnd,
    Status(String),
    /// A plain notice addressed to the user, with none of `Status`'s side effects — it does not
    /// flip the app to Working or open an execution step. Boot-time announcements used `Status`
    /// and so made a freshly launched, idle session render as a running turn.
    Notice(String),
    Error(String),
    /// Which provider and model the agent actually holds, and whether a credential resolved for
    /// them. The UI used to keep its own optimistic copy, which drifted from the agent the moment
    /// a switch failed — or the moment the agent stopped existing.
    Ready {
        provider: String,
        model: String,
        credential: bool,
    },
    /// Structured workspace snapshot — lets the Context panel show Project/Contract/Network
    /// without parsing free-form Status strings.
    Workspace(WorkspaceSnapshot),
    McpStatus(Vec<McpServerStatus>),
    /// A tool that writes or signs is asking to run. The turn is blocked until the answer comes
    /// back on the approval channel.
    Approval(ApprovalRequest),
    /// How a tool call ended. Without this the UI only ever heard that a tool *started*: a failure
    /// went into the `tool_result` and the session log, and the trace went on showing the step in
    /// exactly the same state as one that had succeeded.
    ToolFinished {
        name: String,
        ok: bool,
    },
    /// The conversation a `--resume` restored, for the transcript. The agent got its history back
    /// either way; without this the screen stayed empty and a resumed session was indistinguishable
    /// from a new one.
    History(Vec<TranscriptEntry>),
    /// What a locally-served provider actually has installed. Empty for a remote provider, whose
    /// catalogue cannot be enumerated from here. Pushed from the agent because asking is a network
    /// call, and the UI thread must never make one.
    LocalModels(Vec<String>),
    /// How much of the context window this conversation is currently occupying, and how big that
    /// window turned out to be. Pushed from the agent because the window is not a constant the UI
    /// could derive: for Ollama it is a server setting read back over the wire per turn. Without
    /// it, a local session filled up, compacted, and lost its early turns with nothing on screen
    /// having ever suggested the ceiling was close.
    Context {
        used: usize,
        window: usize,
    },
    /// Take back the reply just streamed: it turned out to be a tool call the model wrote as text.
    /// The text reaches the screen chunk by chunk, long before the finished blocks can be
    /// inspected, so recovering the call is not enough on its own — the JSON is already on screen.
    RetractResponse,
}

pub struct Channels {
    pub user_tx: mpsc::UnboundedSender<UserCommand>,
    pub user_rx: mpsc::UnboundedReceiver<UserCommand>,
    pub agent_tx: mpsc::UnboundedSender<AgentUpdate>,
    pub agent_rx: mpsc::UnboundedReceiver<AgentUpdate>,
    pub cancel: CancelFlag,
    /// Answers to `AgentUpdate::Approval`. A channel of its own rather than a `UserCommand`, for
    /// the same reason `CancelFlag` is not one: the agent is inside the turn when it asks, and so
    /// is not reading `user_rx`. Here it is waiting on exactly this receiver and nothing else.
    pub approval_tx: mpsc::UnboundedSender<ApprovalDecision>,
    pub approval_rx: mpsc::UnboundedReceiver<ApprovalDecision>,
}

impl Channels {
    pub fn new() -> Self {
        let (user_tx, user_rx) = mpsc::unbounded_channel();
        let (agent_tx, agent_rx) = mpsc::unbounded_channel();
        let (approval_tx, approval_rx) = mpsc::unbounded_channel();
        Self {
            user_tx,
            user_rx,
            agent_tx,
            agent_rx,
            cancel: CancelFlag::default(),
            approval_tx,
            approval_rx,
        }
    }
}