Skip to main content

ocserve_http/
tui.rs

1//! `/tui/*` external-controller group (PLAN §17; freeze parity probed live
2//! 2026-10-08 against upstream 1.18.31).
3//!
4//! Semantics (v1 groups/tui.ts + handlers/tui.ts): every endpoint publishes
5//! onto the global event stream and returns `true`; the attached TUI
6//! (packages/tui/src/app.tsx:987) consumes `tui.command.execute`,
7//! `tui.toast.show`, `tui.session.select`, `tui.prompt.append` and acts.
8//! Primary external caller: the VS Code extension's `/tui/append-prompt`.
9//!
10//! Faithful quirks kept: `open-themes` publishes `session.list` (upstream
11//! bug, replicated); unknown `execute-command` values publish an event whose
12//! `properties` omit `command` (JS `undefined` drops from JSON); toast
13//! `duration` defaults to 5000.
14//!
15//! Control rendezvous (`/tui/control/next` GET long-poll + `/response` POST)
16//! is the queue pair from v1 shared/tui-control.ts. No production caller
17//! exists in-tree (test harness only); implemented with bounded channels so
18//! a client disconnect releases the waiter.
19
20use crate::AppState;
21use axum::Json;
22use axum::extract::State;
23use axum::http::StatusCode;
24use serde_json::{Value, json};
25use std::sync::Arc;
26
27/// Bounded rendezvous for the control queue (AGENTS §2.3: every channel has a
28/// declared bound). Depth 16 is far beyond any observed use; submitters block
29/// only when the queue is full and no consumer is polling.
30const CONTROL_CAP: usize = 16;
31
32pub struct TuiControl {
33    tx_req: tokio::sync::mpsc::Sender<Value>,
34    rx_req: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Value>>,
35    /// Response side (no production consumer today — in-process only).
36    #[allow(dead_code)]
37    tx_res: tokio::sync::mpsc::Sender<Value>,
38    #[allow(dead_code)]
39    rx_res: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Value>>,
40}
41
42impl Default for TuiControl {
43    fn default() -> Self {
44        let (tx_req, rx_req) = tokio::sync::mpsc::channel(CONTROL_CAP);
45        let (tx_res, rx_res) = tokio::sync::mpsc::channel(CONTROL_CAP);
46        Self {
47            tx_req,
48            rx_req: tokio::sync::Mutex::new(rx_req),
49            tx_res,
50            rx_res: tokio::sync::Mutex::new(rx_res),
51        }
52    }
53}
54
55impl TuiControl {
56    /// Long-poll: resolves with the next queued request, or `None` when the
57    /// client disconnects (the handler future is dropped by axum).
58    pub async fn next_request(&self) -> Option<Value> {
59        self.rx_req.lock().await.recv().await
60    }
61
62    /// `false` = no consumer holds the response receiver (queue gone).
63    pub async fn submit_response(&self, body: Value) -> bool {
64        self.tx_res.send(body).await.is_ok()
65    }
66
67    /// Producer side of the request queue (used by in-process consumers /
68    /// tests; no production caller today — mirrors upstream).
69    #[allow(dead_code)]
70    pub async fn submit_request(&self, body: Value) -> bool {
71        self.tx_req.send(body).await.is_ok()
72    }
73}
74
75/// v1 commandAliases: legacy names → canonical keymap commands.
76const COMMAND_ALIASES: &[(&str, &str)] = &[
77    ("session_new", "session.new"),
78    ("session_share", "session.share"),
79    ("session_interrupt", "session.interrupt"),
80    ("session_compact", "session.compact"),
81    ("messages_page_up", "session.page.up"),
82    ("messages_page_down", "session.page.down"),
83    ("messages_line_up", "session.line.up"),
84    ("messages_line_down", "session.line.down"),
85    ("messages_half_page_up", "session.half.page.up"),
86    ("messages_half_page_down", "session.half.page.down"),
87    ("messages_first", "session.first"),
88    ("messages_last", "session.last"),
89    ("agent_cycle", "agent.cycle"),
90];
91
92fn publish(st: &Arc<AppState>, event_type: &str, props: Value) {
93    let dir = st.paths["directory"].as_str().unwrap_or("/");
94    st.bus
95        .publish(ocserve_core::event::frame(dir, event_type, props));
96}
97
98fn publish_command(st: &Arc<AppState>, command: Option<&str>) {
99    // JS `{ command: undefined }` serializes to `{}` — replicate exactly.
100    let props = match command {
101        Some(c) => json!({"command": c}),
102        None => json!({}),
103    };
104    publish(st, "tui.command.execute", props);
105}
106
107/// Effect payload-decode envelope (probed live: `/tui/select-session` with a
108/// non-`ses` id returns this shape).
109pub fn bad_request_payload(message: String) -> axum::response::Response {
110    use axum::response::IntoResponse;
111    (
112        StatusCode::BAD_REQUEST,
113        Json(json!({
114            "name": "BadRequest",
115            "data": { "message": message, "kind": "Payload" }
116        })),
117    )
118        .into_response()
119}
120
121fn not_found(message: String) -> axum::response::Response {
122    use axum::response::IntoResponse;
123    (
124        StatusCode::NOT_FOUND,
125        Json(json!({
126            "name": "NotFoundError",
127            "data": { "message": message }
128        })),
129    )
130        .into_response()
131}
132
133// Individual endpoints (each publishes one event and answers `true`).
134
135pub async fn append_prompt(
136    State(st): State<Arc<AppState>>,
137    body: Option<Json<Value>>,
138) -> Json<Value> {
139    let text = body
140        .as_ref()
141        .and_then(|Json(v)| v.get("text"))
142        .and_then(|t| t.as_str())
143        .unwrap_or_default()
144        .to_string();
145    publish(&st, "tui.prompt.append", json!({"text": text}));
146    Json(json!(true))
147}
148
149pub async fn open_help(State(st): State<Arc<AppState>>) -> Json<Value> {
150    publish_command(&st, Some("help.show"));
151    Json(json!(true))
152}
153
154pub async fn open_sessions(State(st): State<Arc<AppState>>) -> Json<Value> {
155    publish_command(&st, Some("session.list"));
156    Json(json!(true))
157}
158
159/// Upstream quirk, replicated deliberately (handlers/tui.ts:51-54):
160/// open-themes publishes `session.list`.
161pub async fn open_themes(State(st): State<Arc<AppState>>) -> Json<Value> {
162    publish_command(&st, Some("session.list"));
163    Json(json!(true))
164}
165
166pub async fn open_models(State(st): State<Arc<AppState>>) -> Json<Value> {
167    publish_command(&st, Some("model.list"));
168    Json(json!(true))
169}
170
171pub async fn submit_prompt(State(st): State<Arc<AppState>>) -> Json<Value> {
172    publish_command(&st, Some("prompt.submit"));
173    Json(json!(true))
174}
175
176pub async fn clear_prompt(State(st): State<Arc<AppState>>) -> Json<Value> {
177    publish_command(&st, Some("prompt.clear"));
178    Json(json!(true))
179}
180
181pub async fn execute_command(
182    State(st): State<Arc<AppState>>,
183    body: Option<Json<Value>>,
184) -> axum::response::Response {
185    use axum::response::IntoResponse;
186    let Some(Json(v)) = body else {
187        return bad_request_payload("Expected object".into());
188    };
189    let Some(cmd) = v.get("command").and_then(|c| c.as_str()) else {
190        return bad_request_payload("Expected a string at [\"command\"]".into());
191    };
192    let canonical = COMMAND_ALIASES
193        .iter()
194        .find(|(legacy, _)| *legacy == cmd)
195        .map(|(_, canon)| *canon);
196    publish_command(&st, canonical);
197    Json(json!(true)).into_response()
198}
199
200pub async fn show_toast(State(st): State<Arc<AppState>>, body: Option<Json<Value>>) -> Json<Value> {
201    let v = body.map(|Json(v)| v).unwrap_or_else(|| json!({}));
202    let mut props = serde_json::Map::new();
203    if let Some(t) = v.get("title").and_then(|t| t.as_str()) {
204        props.insert("title".into(), json!(t));
205    }
206    props.insert(
207        "message".into(),
208        json!(
209            v.get("message")
210                .and_then(|m| m.as_str())
211                .unwrap_or_default()
212        ),
213    );
214    props.insert(
215        "variant".into(),
216        json!(v.get("variant").and_then(|x| x.as_str()).unwrap_or("info")),
217    );
218    props.insert(
219        "duration".into(),
220        json!(v.get("duration").and_then(|d| d.as_u64()).unwrap_or(5000)),
221    );
222    publish(&st, "tui.toast.show", Value::Object(props));
223    Json(json!(true))
224}
225
226/// Union dispatch (v1 handlers/tui.ts:86-96): unknown types answer `true`
227/// without publishing.
228pub async fn publish_event(
229    State(st): State<Arc<AppState>>,
230    body: Option<Json<Value>>,
231) -> Json<Value> {
232    if let Some(Json(v)) = body {
233        let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or_default();
234        let props = v.get("properties").cloned().unwrap_or_else(|| json!({}));
235        match ty {
236            "tui.prompt.append" => publish(&st, "tui.prompt.append", props),
237            "tui.command.execute" => publish(&st, "tui.command.execute", props),
238            "tui.toast.show" => publish(&st, "tui.toast.show", props),
239            "tui.session.select" => publish(&st, "tui.session.select", props),
240            _ => {}
241        }
242    }
243    Json(json!(true))
244}
245
246pub async fn select_session(
247    State(st): State<Arc<AppState>>,
248    body: Option<Json<Value>>,
249) -> axum::response::Response {
250    use axum::response::IntoResponse;
251    let Some(Json(v)) = body else {
252        return bad_request_payload("Expected object".into());
253    };
254    let Some(sid) = v.get("sessionID").and_then(|s| s.as_str()) else {
255        return bad_request_payload("Expected a string at [\"sessionID\"]".into());
256    };
257    if !sid.starts_with("ses") {
258        // Exact upstream message (probed live, including the location trailer).
259        return bad_request_payload(format!(
260            "Expected a string starting with \"ses\", got \"{sid}\"\n  at [\"sessionID\"]"
261        ));
262    }
263    let exists = {
264        let sid = sid.to_string();
265        crate::run_blocking(&st.db, move |db| ocserve_store::session_exists(db, &sid))
266            .await
267            .unwrap_or(false)
268    };
269    if !exists {
270        return not_found(format!("Session not found: {sid}"));
271    }
272    publish(&st, "tui.session.select", json!({"sessionID": sid}));
273    Json(json!(true)).into_response()
274}
275
276pub async fn control_next(State(st): State<Arc<AppState>>) -> axum::response::Response {
277    use axum::response::IntoResponse;
278    match st.tui.next_request().await {
279        Some(req) => Json(req).into_response(),
280        // Client went away while waiting; axum will discard the response.
281        None => StatusCode::NO_CONTENT.into_response(),
282    }
283}
284
285pub async fn control_response(
286    State(st): State<Arc<AppState>>,
287    body: Option<Json<Value>>,
288) -> Json<Value> {
289    let v = body.map(|Json(v)| v).unwrap_or(Value::Null);
290    let _ = st.tui.submit_response(v).await;
291    Json(json!(true))
292}