1use crate::AppState;
21use axum::Json;
22use axum::extract::State;
23use axum::http::StatusCode;
24use serde_json::{Value, json};
25use std::sync::Arc;
26
27const 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 #[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 pub async fn next_request(&self) -> Option<Value> {
59 self.rx_req.lock().await.recv().await
60 }
61
62 pub async fn submit_response(&self, body: Value) -> bool {
64 self.tx_res.send(body).await.is_ok()
65 }
66
67 #[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
75const 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 let props = match command {
101 Some(c) => json!({"command": c}),
102 None => json!({}),
103 };
104 publish(st, "tui.command.execute", props);
105}
106
107pub 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
133pub 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
159pub 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
226pub 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 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 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}