git.iko.soy/opencode-android
on 3dd06b2 help sign in
494 lines 16 kB history raw
1use crux_core::{
2 App, Command,
3 macros::effect,
4 render::{RenderOperation, render},
5};
6use crux_http::protocol::HttpRequest;
7use facet::Facet;
8use serde::{Deserialize, Serialize};
10// ─── Events ────────────────────────────────────────────────────────────────
12#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
13#[repr(C)]
14pub enum Event {
15 // Lifecycle
16 Start,
17 ServerUrlChanged(String),
18 Connect,
20 // Session list
21 LoadSessions,
22 // events local to the core
23 #[serde(skip)]
24 #[facet(skip)]
25 SessionsLoaded(#[facet(opaque)] HttpResult<String>),
26 SelectSession(String),
27 CreateSession,
28 SessionCreated(#[facet(opaque)] HttpResult<String>),
30 // Chat
31 LoadMessages(String),
32 MessagesLoaded(#[facet(opaque)] HttpResult<String>),
33 SendMessage(String),
34 MessageSent(#[facet(opaque)] HttpResult<String>),
36 // SSE event received
37 EventReceived(String),
39 // Navigation
40 NavigateToChat(String),
41 NavigateToSessions,
43 // Errors
44 DismissError,
47// ─── Effects ───────────────────────────────────────────────────────────────
49#[effect(facet_typegen)]
50#[derive(Debug)]
51pub enum Effect {
52 Render(RenderOperation),
53 Http(HttpRequest),
56// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
58pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
60// ─── Model ─────────────────────────────────────────────────────────────────
62#[derive(Default)]
63pub struct Model {
64 server_url: String,
65 connected: bool,
66 loading: bool,
67 error: Option<String>,
68 sessions: Vec<SessionSummary>,
69 current_session_id: Option<String>,
70 messages: Vec<MessageView>,
71 draft_message: String,
74#[derive(Clone, Debug, Default)]
75pub struct SessionSummary {
76 pub id: String,
77 pub title: String,
80#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
81pub struct MessageView {
82 pub id: String,
83 pub role: String,
84 pub text: String,
85 pub time: u64,
88// ─── ViewModel ─────────────────────────────────────────────────────────────
90#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
91pub struct ViewModel {
92 pub screen: Screen,
93 pub server_url: String,
94 pub connected: bool,
95 pub loading: bool,
96 pub error: Option<String>,
97 pub sessions: Vec<SessionView>,
98 pub current_session_id: Option<String>,
99 pub messages: Vec<MessageView>,
100 pub draft_message: String,
103#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
104#[repr(C)]
105pub enum Screen {
106 #[default]
107 Connect,
108 Sessions,
109 Chat,
112#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
113pub struct SessionView {
114 pub id: String,
115 pub title: String,
118// ─── App ───────────────────────────────────────────────────────────────────
120#[derive(Default)]
121pub struct OpencodeApp;
123impl App for OpencodeApp {
124 type Event = Event;
125 type Model = Model;
126 type ViewModel = ViewModel;
127 type Effect = Effect;
129 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
130 match event {
131 Event::Start => {
132 model.server_url = "http://localhost:4096".to_string();
133 render()
134 }
136 Event::ServerUrlChanged(url) => {
137 model.server_url = url;
138 render()
139 }
141 Event::Connect => {
142 model.loading = true;
143 model.error = None;
144 let url = format!("{}/global/health", model.server_url);
145 Http::get(&url).expect_string().build().then_send(|_| Event::LoadSessions)
146 }
148 Event::LoadSessions => {
149 model.loading = true;
150 let url = format!("{}/session", model.server_url);
151 Http::get(&url).expect_string().build().then_send(Event::SessionsLoaded)
152 }
154 Event::SessionsLoaded(result) => {
155 model.loading = false;
156 match result {
157 Ok(mut response) => {
158 let body = response.take_body().unwrap_or_default();
159 model.sessions = parse_sessions(&body);
160 model.connected = true;
161 render()
162 }
163 Err(e) => {
164 model.error = Some(format!("Failed to load sessions: {e}"));
165 render()
166 }
167 }
168 }
170 Event::SelectSession(id) => {
171 model.current_session_id = Some(id.clone());
172 Command::event(Event::NavigateToChat(id))
173 }
175 Event::NavigateToChat(id) => {
176 model.current_session_id = Some(id.clone());
177 model.messages.clear();
178 render().and(Command::event(Event::LoadMessages(id)))
179 }
181 Event::NavigateToSessions => {
182 model.current_session_id = None;
183 model.messages.clear();
184 render()
185 }
187 Event::CreateSession => {
188 model.loading = true;
189 let url = format!("{}/session", model.server_url);
190 Http::post(&url).expect_string().build().then_send(Event::SessionCreated)
191 }
193 Event::SessionCreated(result) => {
194 model.loading = false;
195 match result {
196 Ok(mut response) => {
197 let body = response.take_body().unwrap_or_default();
198 let id = parse_session_id(&body);
199 Command::event(Event::NavigateToChat(id))
200 }
201 Err(e) => {
202 model.error = Some(format!("Failed to create session: {e}"));
203 render()
204 }
205 }
206 }
208 Event::LoadMessages(session_id) => {
209 model.loading = true;
210 let url = format!("{}/session/{}/message", model.server_url, session_id);
211 Http::get(&url).expect_string().build().then_send(Event::MessagesLoaded)
212 }
214 Event::MessagesLoaded(result) => {
215 model.loading = false;
216 match result {
217 Ok(mut response) => {
218 let body = response.take_body().unwrap_or_default();
219 model.messages = parse_messages(&body);
220 render()
221 }
222 Err(e) => {
223 model.error = Some(format!("Failed to load messages: {e}"));
224 render()
225 }
226 }
227 }
229 Event::SendMessage(text) => {
230 let session_id = match &model.current_session_id {
231 Some(id) => id.clone(),
232 None => return render(),
233 };
234 model.draft_message.clear();
235 let url = format!(
236 "{}/session/{}/prompt_async",
237 model.server_url, session_id
238 );
239 let body = format!(
240 r#"{{"sessionID":"{}","parts":[{{"type":"text","text":"{}"}}]}}"#,
241 session_id, text
242 );
243 Http::post(&url)
244 .header("Content-Type", "application/json")
245 .body_bytes(body.into_bytes())
246 .expect_string()
247 .build()
248 .then_send(Event::MessageSent)
249 }
251 Event::MessageSent(result) => {
252 match result {
253 Ok(_) => {
254 // Reload messages to get the response
255 match &model.current_session_id {
256 Some(id) => Command::event(Event::LoadMessages(id.clone())),
257 None => render(),
258 }
259 }
260 Err(e) => {
261 model.error = Some(format!("Failed to send message: {e}"));
262 render()
263 }
264 }
265 }
267 Event::EventReceived(_data) => {
268 // SSE event — reload messages to show updates
269 match &model.current_session_id {
270 Some(id) => Command::event(Event::LoadMessages(id.clone())),
271 None => render(),
272 }
273 }
275 Event::DismissError => {
276 model.error = None;
277 render()
278 }
279 }
280 }
282 fn view(&self, model: &Model) -> ViewModel {
283 ViewModel {
284 screen: if model.current_session_id.is_some() {
285 Screen::Chat
286 } else if model.connected {
287 Screen::Sessions
288 } else {
289 Screen::Connect
290 },
291 server_url: model.server_url.clone(),
292 connected: model.connected,
293 loading: model.loading,
294 error: model.error.clone(),
295 sessions: model
296 .sessions
297 .iter()
298 .map(|s| SessionView {
299 id: s.id.clone(),
300 title: s.title.clone(),
301 })
302 .collect(),
303 current_session_id: model.current_session_id.clone(),
304 messages: model.messages.clone(),
305 draft_message: model.draft_message.clone(),
306 }
307 }
310// ─── JSON parsing helpers (naive — the core stays pure) ────────────────────
312fn parse_sessions(json: &str) -> Vec<SessionSummary> {
313 let mut sessions = Vec::new();
314 let mut depth = 0;
315 let mut start = 0;
316 let bytes = json.as_bytes();
318 let mut i = 0;
319 while i < bytes.len() {
320 match bytes[i] {
321 b'{' if depth == 0 => {
322 depth = 1;
323 start = i;
324 }
325 b'{' => depth += 1,
326 b'}' => {
327 depth -= 1;
328 if depth == 0 {
329 let obj = &json[start..=i];
330 let id = extract_string(obj, "id").unwrap_or_default();
331 let title = extract_string(obj, "title").unwrap_or_default();
332 if !id.is_empty() {
333 sessions.push(SessionSummary { id, title });
334 }
335 }
336 }
337 _ => {}
338 }
339 i += 1;
340 }
341 sessions
344fn parse_session_id(json: &str) -> String {
345 extract_string(json, "id").unwrap_or_default()
348fn parse_messages(json: &str) -> Vec<MessageView> {
349 let mut messages = Vec::new();
350 let mut depth = 0;
351 let mut start = 0;
352 let bytes = json.as_bytes();
354 let mut i = 0;
355 while i < bytes.len() {
356 match bytes[i] {
357 b'{' if depth == 0 => {
358 depth = 1;
359 start = i;
360 }
361 b'{' => depth += 1,
362 b'}' => {
363 depth -= 1;
364 if depth == 0 {
365 let obj = &json[start..=i];
366 let id = extract_string(obj, "id").unwrap_or_default();
367 let role = extract_string(obj, "role").unwrap_or_default();
368 let time = extract_number(obj, "created").unwrap_or(0);
369 if !id.is_empty() && !role.is_empty() {
370 messages.push(MessageView {
371 id,
372 role,
373 text: String::new(),
374 time,
375 });
376 }
377 }
378 }
379 _ => {}
380 }
381 i += 1;
382 }
383 messages
386fn extract_string(json: &str, key: &str) -> Option<String> {
387 let pattern = format!("\"{key}\"");
388 let idx = json.find(&pattern)?;
389 let rest = &json[idx + pattern.len()..];
390 let colon = rest.find(':')?;
391 let rest = &rest[colon + 1..];
392 let quote = rest.find('"')?;
393 let rest = &rest[quote + 1..];
394 let end = rest.find('"')?;
395 Some(rest[..end].to_string())
398fn extract_number(json: &str, key: &str) -> Option<u64> {
399 let pattern = format!("\"{key}\"");
400 let idx = json.find(&pattern)?;
401 let rest = &json[idx + pattern.len()..];
402 let colon = rest.find(':')?;
403 let rest = rest[colon + 1..].trim_start();
404 let end = rest
405 .find(|c: char| !c.is_ascii_digit())
406 .unwrap_or(rest.len());
407 rest[..end].parse().ok()
410// ─── HTTP type alias for the command builder ───────────────────────────────
412use crux_http::command::Http;
414#[cfg(test)]
415mod test {
416 use super::*;
418 #[test]
419 fn start_sets_default_server_url() {
420 let app = OpencodeApp;
421 let mut model = Model::default();
422 app.update(Event::Start, &mut model).expect_only_render();
423 assert_eq!(model.server_url, "http://localhost:4096");
424 }
426 #[test]
427 fn server_url_change_updates_model() {
428 let app = OpencodeApp;
429 let mut model = Model::default();
430 app.update(Event::Start, &mut model);
431 app.update(
432 Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
433 &mut model,
434 )
435 .expect_only_render();
436 assert_eq!(model.server_url, "http://10.0.0.1:4096");
437 }
439 #[test]
440 fn connect_emits_http_effect() {
441 let app = OpencodeApp;
442 let mut model = Model::default();
443 app.update(Event::Start, &mut model);
444 let mut cmd = app.update(Event::Connect, &mut model);
445 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
446 assert!(model.loading);
447 }
449 #[test]
450 fn parse_sessions_extracts_id_and_title() {
451 let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
452 let sessions = parse_sessions(json);
453 assert_eq!(sessions.len(), 2);
454 assert_eq!(sessions[0].id, "abc123");
455 assert_eq!(sessions[0].title, "My Session");
456 assert_eq!(sessions[1].id, "def456");
457 }
459 #[test]
460 fn parse_session_id_extracts_id() {
461 let json = r#"{"id":"xyz789","title":"New"}"#;
462 let id = parse_session_id(json);
463 assert_eq!(id, "xyz789");
464 }
466 #[test]
467 fn extract_string_finds_nested_keys() {
468 let json = r#"{"a":"b","id":"target","c":"d"}"#;
469 assert_eq!(extract_string(json, "id"), Some("target".to_string()));
470 assert_eq!(extract_string(json, "missing"), None);
471 }
473 #[test]
474 fn extract_number_parses_values() {
475 let json = r#"{"created":1234567890}"#;
476 assert_eq!(extract_number(json, "created"), Some(1234567890));
477 }
479 #[test]
480 fn navigate_to_sessions_clears_state() {
481 let app = OpencodeApp;
482 let mut model = Model::default();
483 model.current_session_id = Some("test".to_string());
484 model.messages.push(MessageView {
485 id: "m1".to_string(),
486 role: "user".to_string(),
487 text: "hello".to_string(),
488 time: 0,
489 });
490 app.update(Event::NavigateToSessions, &mut model);
491 assert!(model.current_session_id.is_none());
492 assert!(model.messages.is_empty());
493 }