4 render::{RenderOperation, render},
6use crux_http::protocol::HttpRequest;
8use serde::{Deserialize, Serialize};
10// ─── Events ────────────────────────────────────────────────────────────────
12#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
17 ServerUrlChanged(String),
22 // events local to the core
25 SessionsLoaded(#[facet(opaque)] HttpResult<String>),
26 SelectSession(String),
28 SessionCreated(#[facet(opaque)] HttpResult<String>),
32 MessagesLoaded(#[facet(opaque)] HttpResult<String>),
34 MessageSent(#[facet(opaque)] HttpResult<String>),
37 EventReceived(String),
40 NavigateToChat(String),
47// ─── Effects ───────────────────────────────────────────────────────────────
49#[effect(facet_typegen)]
52 Render(RenderOperation),
56// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
58pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
60// ─── Model ─────────────────────────────────────────────────────────────────
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 {
80#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
81pub struct MessageView {
88// ─── ViewModel ─────────────────────────────────────────────────────────────
90#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
93 pub server_url: String,
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)]
112#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
113pub struct SessionView {
118// ─── App ───────────────────────────────────────────────────────────────────
121pub struct OpencodeApp;
123impl App for OpencodeApp {
126 type ViewModel = ViewModel;
127 type Effect = Effect;
129 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
132 model.server_url = "http://localhost:4096".to_string();
136 Event::ServerUrlChanged(url) => {
137 model.server_url = url;
142 model.loading = true;
144 let url = format!("{}/global/health", model.server_url);
145 Http::get(&url).expect_string().build().then_send(|_| Event::LoadSessions)
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)
154 Event::SessionsLoaded(result) => {
155 model.loading = false;
157 Ok(mut response) => {
158 let body = response.take_body().unwrap_or_default();
159 model.sessions = parse_sessions(&body);
160 model.connected = true;
164 model.error = Some(format!("Failed to load sessions: {e}"));
170 Event::SelectSession(id) => {
171 model.current_session_id = Some(id.clone());
172 Command::event(Event::NavigateToChat(id))
175 Event::NavigateToChat(id) => {
176 model.current_session_id = Some(id.clone());
177 model.messages.clear();
178 render().and(Command::event(Event::LoadMessages(id)))
181 Event::NavigateToSessions => {
182 model.current_session_id = None;
183 model.messages.clear();
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)
193 Event::SessionCreated(result) => {
194 model.loading = false;
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))
202 model.error = Some(format!("Failed to create session: {e}"));
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)
214 Event::MessagesLoaded(result) => {
215 model.loading = false;
217 Ok(mut response) => {
218 let body = response.take_body().unwrap_or_default();
219 model.messages = parse_messages(&body);
223 model.error = Some(format!("Failed to load messages: {e}"));
229 Event::SendMessage(text) => {
230 let session_id = match &model.current_session_id {
231 Some(id) => id.clone(),
232 None => return render(),
234 model.draft_message.clear();
236 "{}/session/{}/prompt_async",
237 model.server_url, session_id
240 r#"{{"sessionID":"{}","parts":[{{"type":"text","text":"{}"}}]}}"#,
244 .header("Content-Type", "application/json")
245 .body_bytes(body.into_bytes())
248 .then_send(Event::MessageSent)
251 Event::MessageSent(result) => {
254 // Reload messages to get the response
255 match &model.current_session_id {
256 Some(id) => Command::event(Event::LoadMessages(id.clone())),
261 model.error = Some(format!("Failed to send message: {e}"));
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())),
275 Event::DismissError => {
282 fn view(&self, model: &Model) -> ViewModel {
284 screen: if model.current_session_id.is_some() {
286 } else if model.connected {
291 server_url: model.server_url.clone(),
292 connected: model.connected,
293 loading: model.loading,
294 error: model.error.clone(),
298 .map(|s| SessionView {
300 title: s.title.clone(),
303 current_session_id: model.current_session_id.clone(),
304 messages: model.messages.clone(),
305 draft_message: model.draft_message.clone(),
310// ─── JSON parsing helpers (naive — the core stays pure) ────────────────────
312fn parse_sessions(json: &str) -> Vec<SessionSummary> {
313 let mut sessions = Vec::new();
316 let bytes = json.as_bytes();
319 while i < bytes.len() {
321 b'{' 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();
333 sessions.push(SessionSummary { id, title });
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();
352 let bytes = json.as_bytes();
355 while i < bytes.len() {
357 b'{' 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 {
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();
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;
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");
427 fn server_url_change_updates_model() {
428 let app = OpencodeApp;
429 let mut model = Model::default();
430 app.update(Event::Start, &mut model);
432 Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
435 .expect_only_render();
436 assert_eq!(model.server_url, "http://10.0.0.1:4096");
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);
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");
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");
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);
474 fn extract_number_parses_values() {
475 let json = r#"{"created":1234567890}"#;
476 assert_eq!(extract_number(json, "created"), Some(1234567890));
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(),
490 app.update(Event::NavigateToSessions, &mut model);
491 assert!(model.current_session_id.is_none());
492 assert!(model.messages.is_empty());
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list