Skip to main content

opencode_voice/bridge/
client.rs

1//! HTTP client for the OpenCode API.
2
3use anyhow::{Context, Result};
4use base64::{engine::general_purpose::STANDARD, Engine as _};
5use reqwest::Client;
6use serde_json::json;
7use std::time::Duration;
8
9use crate::approval::types::PermissionReply;
10
11/// HTTP client for all OpenCode API endpoints.
12pub struct OpenCodeBridge {
13    client: Client,
14    base_url: String,
15    password: Option<String>,
16}
17
18impl OpenCodeBridge {
19    /// Creates a new bridge client.
20    ///
21    /// `url` is typically "http://localhost", `port` is the OpenCode server port.
22    pub fn new(url: &str, port: u16, password: Option<String>) -> Self {
23        let base_url = format!("{}:{}", url.trim_end_matches('/'), port);
24        OpenCodeBridge {
25            client: Client::new(),
26            base_url,
27            password,
28        }
29    }
30
31    /// Returns the base URL (e.g. "http://localhost:4096").
32    pub fn get_base_url(&self) -> &str {
33        &self.base_url
34    }
35
36    /// Builds the Authorization header value for Basic auth.
37    fn auth_header(&self) -> Option<String> {
38        self.password.as_ref().map(|pw| {
39            let credentials = format!(":{}", pw);
40            format!("Basic {}", STANDARD.encode(credentials))
41        })
42    }
43
44    /// Performs a POST request with JSON body.
45    async fn post_json(&self, path: &str, body: serde_json::Value) -> Result<()> {
46        let url = format!("{}{}", self.base_url, path);
47        let mut req = self.client.post(&url).json(&body);
48        if let Some(auth) = self.auth_header() {
49            req = req.header("Authorization", auth);
50        }
51        let resp = req
52            .send()
53            .await
54            .with_context(|| friendly_connection_error(&self.base_url))?;
55        if !resp.status().is_success() {
56            let status = resp.status();
57            let body = resp.text().await.unwrap_or_default();
58            anyhow::bail!("OpenCode API error {}: {}", status, body);
59        }
60        Ok(())
61    }
62
63    /// Injects text into OpenCode's prompt.
64    pub async fn append_prompt(
65        &self,
66        text: &str,
67        directory: Option<&str>,
68        workspace: Option<&str>,
69    ) -> Result<()> {
70        let mut url = format!("{}/tui/append-prompt", self.base_url);
71        let mut params = Vec::new();
72        if let Some(dir) = directory {
73            params.push(format!("directory={}", urlencoding_encode(dir)));
74        }
75        if let Some(ws) = workspace {
76            params.push(format!("workspace={}", urlencoding_encode(ws)));
77        }
78        if !params.is_empty() {
79            url.push('?');
80            url.push_str(&params.join("&"));
81        }
82        let mut req = self.client.post(&url).json(&json!({"text": text}));
83        if let Some(auth) = self.auth_header() {
84            req = req.header("Authorization", auth);
85        }
86        let resp = req
87            .send()
88            .await
89            .with_context(|| friendly_connection_error(&self.base_url))?;
90        if !resp.status().is_success() {
91            let status = resp.status();
92            anyhow::bail!("append_prompt failed: {}", status);
93        }
94        Ok(())
95    }
96
97    /// Submits the OpenCode prompt.
98    pub async fn submit_prompt(&self) -> Result<()> {
99        self.post_json("/tui/submit-prompt", json!({})).await
100    }
101
102    /// Checks if OpenCode is reachable. Never panics.
103    pub async fn is_connected(&self) -> bool {
104        let url = format!("{}/", self.base_url);
105        let client = Client::builder()
106            .timeout(Duration::from_secs(2))
107            .build()
108            .unwrap_or_else(|_| Client::new());
109        let mut req = client.get(&url);
110        if let Some(auth) = self.auth_header() {
111            req = req.header("Authorization", auth);
112        }
113        req.send().await.is_ok()
114    }
115
116    /// Replies to a permission request.
117    pub async fn reply_permission(
118        &self,
119        id: &str,
120        reply: PermissionReply,
121        message: Option<&str>,
122    ) -> Result<()> {
123        let path = format!("/permission/{}/reply", id);
124        let mut body = json!({"reply": reply});
125        if let Some(msg) = message {
126            body["message"] = json!(msg);
127        }
128        self.post_json(&path, body).await
129    }
130
131    /// Replies to a question request with answers.
132    pub async fn reply_question(&self, id: &str, answers: Vec<Vec<String>>) -> Result<()> {
133        let path = format!("/question/{}/reply", id);
134        self.post_json(&path, json!({"answers": answers})).await
135    }
136
137    /// Rejects (dismisses) a question request.
138    pub async fn reject_question(&self, id: &str) -> Result<()> {
139        let path = format!("/question/{}/reject", id);
140        self.post_json(&path, json!({})).await
141    }
142}
143
144fn friendly_connection_error(base_url: &str) -> String {
145    format!(
146        "Cannot connect to OpenCode at {}. Make sure OpenCode is running with --port flag: opencode --port <port>",
147        base_url
148    )
149}
150
151fn urlencoding_encode(s: &str) -> String {
152    s.chars()
153        .flat_map(|c| {
154            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
155                vec![c]
156            } else {
157                format!("%{:02X}", c as u32).chars().collect::<Vec<_>>()
158            }
159        })
160        .collect()
161}