5 render::{RenderOperation, render},
7use crux_http::command::{Http, RequestBuilder};
8use crux_http::protocol::HttpRequest;
10use serde::{Deserialize, Serialize};
12// ─── Events ────────────────────────────────────────────────────────────────
14#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
17 // ── Type-generated events (sent from Shell → Core).
18 // These MUST be contiguous starting at index 0 to match the
19 // generated Kotlin/Swift/TS enum indices. Do not interleave
20 // skipped variants here. ──────────────────────────────────────
24 ServerUrlChanged(String),
25 UsernameChanged(String),
26 PasswordChanged(String),
32 SelectSession(String),
40 EventReceived(String),
43 NavigateToChat(String),
52 // Cancel waiting for the assistant reply (client-side stop).
56 DeleteSession(String),
57 // Retry sending a failed message by its (local) message id.
60 // ── Internal events (skipped from typegen, sent only via then_send).
61 // These occupy higher discriminant indices that are never
62 // produced by the shell, so the index gap is harmless. ─────────
66 SessionsLoaded(#[facet(opaque)] HttpResult<String>),
69 SessionCreated(#[facet(opaque)] HttpResult<String>),
72 MessagesLoaded(#[facet(opaque)] HttpResult<String>),
75 MessageSent(#[facet(opaque)] HttpResult<String>),
81 HealthProbed(#[facet(opaque)] HttpResult<String>),
84 SessionDeleted(#[facet(opaque)] HttpResult<String>),
87// ─── Effects ───────────────────────────────────────────────────────────────
89#[effect(facet_typegen)]
92 Render(RenderOperation),
96// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
98pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
100// ─── Model ─────────────────────────────────────────────────────────────────
107 /// Set once the server replies 401 to an unauthenticated probe.
108 /// The view surfaces this so the shell can show credential fields.
110 /// Set once a request with credentials succeeds. All subsequent
111 /// requests carry the Authorization header.
115 error: Option<String>,
116 sessions: Vec<SessionSummary>,
117 current_session_id: Option<String>,
118 current_session_title: String,
119 messages: Vec<MessageView>,
120 draft_message: String,
121 /// True between sending a message and the assistant reply arriving.
123 /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
124 crash_logs: Vec<String>,
127#[derive(Clone, Debug, Default)]
128pub struct SessionSummary {
133#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
135pub enum MessageStatus {
142#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
143pub struct MessageView {
148 pub status: MessageStatus,
151// ─── ViewModel ─────────────────────────────────────────────────────────────
153#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
154pub struct ViewModel {
156 pub server_url: String,
157 pub username: String,
158 pub password: String,
159 pub auth_required: bool,
162 pub error: Option<String>,
163 pub sessions: Vec<SessionView>,
164 pub current_session_id: Option<String>,
165 pub current_session_title: String,
166 pub messages: Vec<MessageView>,
167 pub draft_message: String,
168 /// True while waiting for the assistant to reply after the user sends.
169 pub generating: bool,
170 pub crash_log_count: u32,
171 pub latest_crash_log: Option<String>,
174#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
183#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
184pub struct SessionView {
189// ─── App ───────────────────────────────────────────────────────────────────
192pub struct OpencodeApp;
195 /// Build a GET request to `path`, adding the Basic-Auth header when the
196 /// model has credentials. The header is always added once `authed` is true
197 /// (i.e. after the server has accepted credentials), and also during the
198 /// credential-retry attempt itself.
199 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
200 let url = format!("{}{path}", model.server_url);
201 let req = Http::get(&url).expect_string();
202 self.with_auth(req, model)
205 /// Build a POST request to `path` with the given body bytes.
206 fn post(&self, model: &Model, path: &str, body: Vec<u8>) -> RequestBuilder<Effect, Event, String> {
207 let url = format!("{}{path}", model.server_url);
208 let req = Http::post(&url).expect_string().body_bytes(body);
209 self.with_auth(req, model)
212 /// Build a DELETE request to `path`.
213 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
214 let url = format!("{}{path}", model.server_url);
215 let req = Http::delete(&url).expect_string();
216 self.with_auth(req, model)
219 /// Attach the Basic-Auth `Authorization` header if the model carries
220 /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
221 /// `username:password`.
224 req: RequestBuilder<Effect, Event, E>,
226 ) -> RequestBuilder<Effect, Event, E>
230 if !model.username.is_empty() || !model.password.is_empty() {
231 let creds = format!("{}:{}", model.username, model.password);
232 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
233 req.header("Authorization", format!("Basic {encoded}"))
239 /// Render helper that also stamps the crash-log summary into the view.
240 fn render_with_crashes(&self, _model: &Model) -> Command<Effect, Event> {
241 // render() is the base; the view() function reads the model directly
242 // for crash-log fields, so a plain render suffices.
247impl App for OpencodeApp {
250 type ViewModel = ViewModel;
251 type Effect = Effect;
253 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
256 // Don't reset server_url here — the shell restores it from
257 // persisted storage via ServerUrlChanged before any connect.
261 Event::ServerUrlChanged(url) => {
262 model.server_url = url;
266 Event::UsernameChanged(u) => {
271 Event::PasswordChanged(p) => {
277 model.loading = true;
279 // Probe health. `with_auth` attaches credentials if present,
280 // so a retry after a 401 carries the Authorization header.
281 let req = self.get(model, "/global/health");
282 req.build().then_send(|result| Event::HealthProbed(result))
285 Event::CancelAuth => {
286 model.auth_required = false;
287 model.username.clear();
288 model.password.clear();
289 model.loading = false;
293 Event::LoadSessions => {
294 model.loading = true;
295 self.get(model, "/session").build().then_send(Event::SessionsLoaded)
298 Event::SessionsLoaded(result) => {
299 model.loading = false;
301 Ok(mut response) => {
302 let body = response.take_body().unwrap_or_default();
303 model.sessions = parse_sessions(&body);
304 model.connected = true;
305 // Refresh the current session's title in case it was
306 // populated after creation.
307 if let Some(id) = model.current_session_id.clone() {
308 if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
309 model.current_session_title = s.title.clone();
315 model.error = Some(format!("Failed to load sessions: {e}"));
321 Event::SelectSession(id) => {
322 model.current_session_id = Some(id.clone());
323 model.current_session_title = model
326 .find(|s| s.id == id)
327 .map(|s| s.title.clone())
328 .unwrap_or_default();
329 Command::event(Event::NavigateToChat(id))
332 Event::NavigateToChat(id) => {
333 model.current_session_id = Some(id.clone());
334 model.current_session_title = model
337 .find(|s| s.id == id)
338 .map(|s| s.title.clone())
339 .unwrap_or_default();
340 model.messages.clear();
341 render().and(Command::event(Event::LoadMessages(id)))
344 Event::NavigateToSessions => {
345 model.current_session_id = None;
346 model.current_session_title.clear();
347 model.messages.clear();
348 model.generating = false;
352 Event::NavigateToConnect => {
353 // Return to the connect screen to switch servers / credentials.
354 // Keep the sessions list so a re-connect can refresh in place.
355 model.current_session_id = None;
356 model.current_session_title.clear();
357 model.messages.clear();
358 model.generating = false;
359 model.connected = false;
360 model.auth_required = false;
361 model.loading = false;
365 Event::CreateSession => {
366 model.loading = true;
367 self.post(model, "/session", Vec::new())
369 .then_send(Event::SessionCreated)
372 Event::SessionCreated(result) => {
373 model.loading = false;
375 Ok(mut response) => {
376 let body = response.take_body().unwrap_or_default();
377 let id = parse_session_id(&body);
378 // Some servers echo the title in the created payload;
379 // fall back to "New session" if absent.
380 let title = extract_string(&body, "title")
381 .filter(|t| !t.is_empty())
382 .unwrap_or_else(|| "New session".to_string());
383 model.current_session_title = title.clone();
384 model.sessions.insert(
386 SessionSummary { id: id.clone(), title },
388 Command::event(Event::NavigateToChat(id))
391 model.error = Some(format!("Failed to create session: {e}"));
397 Event::LoadMessages(id) => {
398 model.loading = true;
399 let path = format!("/session/{id}/message");
400 self.get(model, &path).build().then_send(Event::MessagesLoaded)
403 Event::MessagesLoaded(result) => {
404 model.loading = false;
405 model.generating = false;
407 Ok(mut response) => {
408 let body = response.take_body().unwrap_or_default();
409 model.messages = parse_messages(&body);
413 model.error = Some(format!("Failed to load messages: {e}"));
419 Event::SendMessage(text) => {
420 let Some(session_id) = model.current_session_id.clone() else {
423 model.draft_message.clear();
424 model.generating = true;
425 let body = serde_json::json!({
426 "sessionID": session_id,
427 "parts": [{ "type": "text", "text": text }]
431 let path = format!("/session/{session_id}/prompt_async");
432 self.post(model, &path, body)
434 .then_send(Event::MessageSent)
437 Event::MessageSent(result) => {
440 // Reload messages to pick up the assistant reply.
441 if let Some(session_id) = model.current_session_id.clone() {
442 Command::event(Event::LoadMessages(session_id))
444 model.generating = false;
449 model.generating = false;
450 model.error = Some(format!("Failed to send message: {e}"));
456 Event::EventReceived(_data) => {
457 // SSE event — reload messages for the current session.
458 if let Some(session_id) = model.current_session_id.clone() {
459 Command::event(Event::LoadMessages(session_id))
465 Event::CrashLog(entry) => {
466 model.crash_logs.push(entry);
467 self.render_with_crashes(model)
470 Event::DismissError => {
475 Event::DraftChanged(text) => {
476 model.draft_message = text;
480 Event::CancelGeneration => {
481 // Client-side stop: the server-side generation continues, but
482 // we stop showing the "generating" state and return to ready.
483 // A subsequent SSE event / manual refresh will pick up the
484 // assistant reply when it arrives.
485 model.generating = false;
489 Event::DeleteSession(id) => {
490 // Optimistically remove the session from the list. If the
491 // server delete fails, we reload the session list to restore.
492 model.sessions.retain(|s| s.id != id);
493 if model.current_session_id.as_deref() == Some(id.as_str()) {
494 model.current_session_id = None;
495 model.current_session_title.clear();
496 model.messages.clear();
497 model.generating = false;
499 let path = format!("/session/{id}");
500 self.delete(model, &path).build().then_send(Event::SessionDeleted)
503 Event::RetryMessage(_msg_id) => {
504 // No persisted failed-message body to re-send in the core, so
505 // retry is a no-op here. The shell can surface a hint. This
506 // event exists so the UI has a stable retry path and so the
507 // core can grow real per-message retry later without a
512 // ── Internal events (not type-generated, sent via then_send) ───
513 Event::HealthProbed(result) => handle_health_probed(self, model, result),
514 Event::SessionDeleted(result) => {
520 // Optimistic delete failed — reload sessions to
521 // restore the list, and surface the error.
522 model.error = Some(format!("Failed to delete session: {e}"));
523 self.get(model, "/session").build().then_send(Event::SessionsLoaded)
530 fn view(&self, model: &Self::Model) -> Self::ViewModel {
531 let screen = if model.current_session_id.is_some() {
533 } else if model.connected {
541 server_url: model.server_url.clone(),
542 username: model.username.clone(),
543 password: model.password.clone(),
544 auth_required: model.auth_required,
545 connected: model.connected,
546 loading: model.loading,
547 error: model.error.clone(),
551 .map(|s| SessionView {
553 title: s.title.clone(),
556 current_session_id: model.current_session_id.clone(),
557 current_session_title: model.current_session_title.clone(),
558 messages: model.messages.clone(),
559 draft_message: model.draft_message.clone(),
560 generating: model.generating,
561 crash_log_count: model.crash_logs.len() as u32,
562 latest_crash_log: model.crash_logs.last().cloned(),
567// ─── Basic-auth detection ──────────────────────────────────────────────────
569// The opencode server uses HTTP Basic Auth when OPENCODE_SERVER_PASSWORD is set
570// (username defaults to "opencode"). We detect this by probing /global/health
571// without credentials:
573// - 200 → server is open; proceed to load sessions.
574// - 401 → server is behind basic auth; surface `auth_required` and wait for
575// the user to supply credentials. The user then re-issues Connect,
576// which now attaches the Authorization header to the same probe.
577// A 200 on that retry flips `authed` and proceeds.
578// - Other non-2xx → surface as a connection error.
579// - Transport error → surface as a connection error.
581fn handle_health_probed(
584 result: HttpResult<String>,
585) -> Command<Effect, Event> {
586 let has_creds = !model.username.is_empty() || !model.password.is_empty();
589 let status = response.status().as_u16();
590 if status == 401 { model.loading = false;
592 // We sent credentials but the server still rejected them.
593 model.error = Some("Invalid credentials".to_string());
595 // No credentials were sent — server is behind basic auth.
596 // Surface the credential fields and wait for the user.
597 model.auth_required = true;
601 if (200..300).contains(&status) {
605 model.loading = true;
607 .get(model, "/session")
609 .then_send(Event::SessionsLoaded);
611 // Other status codes — treat as connection error.
612 model.loading = false;
613 model.error = Some(format!("Server returned status {status}"));
617 model.loading = false;
618 model.error = Some(format!("Connection failed: {e}"));
624// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
626fn parse_sessions(body: &str) -> Vec<SessionSummary> {
627 let mut sessions = Vec::new();
628 let mut depth = 0i32;
629 let mut start = 0usize;
630 for (i, ch) in body.char_indices() {
641 let obj = &body[start..=i];
642 let id = extract_string(obj, "id").unwrap_or_default();
643 let title = extract_string(obj, "title").unwrap_or_default();
645 sessions.push(SessionSummary {
647 title: if title.is_empty() {
648 "Untitled".to_string()
662fn parse_session_id(body: &str) -> String {
663 extract_string(body, "id").unwrap_or_default()
666fn parse_messages(body: &str) -> Vec<MessageView> {
667 let mut messages = Vec::new();
668 let mut depth = 0i32;
669 let mut start = 0usize;
671 let bytes = body.as_bytes();
672 while i < body.len() {
673 let ch = bytes[i] as char;
684 let obj = &body[start..=i];
685 let id = extract_string(obj, "id").unwrap_or_default();
686 let role = extract_string(obj, "role").unwrap_or_default();
687 let time = extract_number(obj, "created").unwrap_or(0);
689 let text = extract_message_text(obj);
690 messages.push(MessageView { id, role, text, time, status: MessageStatus::Sent });
701/// Extract the text from a message part. opencode messages nest the user/assistant
702/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
703/// first `"parts"` key.
704fn extract_message_text(obj: &str) -> String {
705 if let Some(parts_idx) = obj.find("\"parts\"") {
706 let rest = &obj[parts_idx..];
707 if let Some(text) = extract_string(rest, "text") {
711 extract_string(obj, "text").unwrap_or_default()
714fn extract_string(json: &str, key: &str) -> Option<String> {
715 let pattern = format!("\"{key}\"");
716 let idx = json.find(&pattern)?;
717 let rest = &json[idx + pattern.len()..];
718 let colon = rest.find(':')?;
719 let rest = &rest[colon + 1..];
720 let quote = rest.find('"')?;
721 let rest = &rest[quote + 1..];
722 let end = rest.find('"')?;
723 Some(rest[..end].to_string())
726fn extract_number(json: &str, key: &str) -> Option<u64> {
727 let pattern = format!("\"{key}\"");
728 let idx = json.find(&pattern)?;
729 let rest = &json[idx + pattern.len()..];
730 let colon = rest.find(':')?;
731 let rest = &rest[colon + 1..].trim_start();
733 .find(|c: char| !c.is_ascii_digit())
734 .unwrap_or(rest.len());
735 rest[..end].parse().ok()
738// ─── Tests ─────────────────────────────────────────────────────────────────
745 fn start_does_not_reset_server_url() {
746 // Start no longer overwrites server_url — the shell restores the
747 // persisted value via ServerUrlChanged so the user's last server
748 // survives a relaunch.
749 let app = OpencodeApp;
750 let mut model = Model::default();
751 model.server_url = "http://10.0.0.1:4096".to_string();
752 app.update(Event::Start, &mut model).expect_only_render();
753 assert_eq!(model.server_url, "http://10.0.0.1:4096");
757 fn server_url_change_updates_model() {
758 let app = OpencodeApp;
759 let mut model = Model::default();
760 app.update(Event::Start, &mut model);
762 Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
765 .expect_only_render();
766 assert_eq!(model.server_url, "http://10.0.0.1:4096");
770 fn connect_emits_http_effect() {
771 let app = OpencodeApp;
772 let mut model = Model::default();
773 model.server_url = "http://localhost:4096".to_string();
774 app.update(Event::Start, &mut model);
775 let mut cmd = app.update(Event::Connect, &mut model);
776 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
777 assert!(model.loading);
781 fn parse_sessions_extracts_id_and_title() {
782 let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
783 let sessions = parse_sessions(json);
784 assert_eq!(sessions.len(), 2);
785 assert_eq!(sessions[0].id, "abc123");
786 assert_eq!(sessions[0].title, "My Session");
787 assert_eq!(sessions[1].id, "def456");
791 fn parse_session_id_extracts_id() {
792 let json = r#"{"id":"xyz789","title":"New"}"#;
793 let id = parse_session_id(json);
794 assert_eq!(id, "xyz789");
798 fn extract_string_finds_nested_keys() {
799 let json = r#"{"a":"b","id":"target","c":"d"}"#;
800 assert_eq!(extract_string(json, "id"), Some("target".to_string()));
801 assert_eq!(extract_string(json, "missing"), None);
805 fn extract_number_parses_values() {
806 let json = r#"{"created":1234567890}"#;
807 assert_eq!(extract_number(json, "created"), Some(1234567890));
811 fn navigate_to_sessions_clears_state() {
812 let app = OpencodeApp;
813 let mut model = Model::default();
814 model.current_session_id = Some("test".to_string());
815 model.current_session_title = "Test".to_string();
816 model.generating = true;
817 model.messages.push(MessageView {
818 id: "m1".to_string(),
819 role: "user".to_string(),
820 text: "hello".to_string(),
822 status: MessageStatus::Sent,
824 app.update(Event::NavigateToSessions, &mut model);
825 assert!(model.current_session_id.is_none());
826 assert!(model.current_session_title.is_empty());
827 assert!(model.messages.is_empty());
828 assert!(!model.generating);
832 fn select_session_records_title_from_sessions_list() {
833 let app = OpencodeApp;
834 let mut model = Model::default();
835 model.sessions = vec![
836 SessionSummary { id: "s1".to_string(), title: "First".to_string() },
837 SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
839 app.update(Event::SelectSession("s2".to_string()), &mut model);
840 assert_eq!(model.current_session_id.as_deref(), Some("s2"));
841 assert_eq!(model.current_session_title, "Second");
842 let view = app.view(&model);
843 assert_eq!(view.current_session_title, "Second");
847 fn send_message_sets_generating_until_messages_loaded() {
848 let app = OpencodeApp;
849 let mut model = Model::default();
850 model.server_url = "http://localhost:4096".to_string();
851 model.current_session_id = Some("s1".to_string());
852 app.update(Event::SendMessage("hello".to_string()), &mut model);
853 assert!(model.generating);
854 // Simulate the MessagesLoaded internal event clearing it.
855 let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
856 let result: HttpResult<String> = Ok(ok_resp);
857 app.update(Event::MessagesLoaded(result), &mut model);
858 assert!(!model.generating);
862 fn failed_send_clears_generating() {
863 let app = OpencodeApp;
864 let mut model = Model::default();
865 model.server_url = "http://localhost:4096".to_string();
866 model.current_session_id = Some("s1".to_string());
867 app.update(Event::SendMessage("hello".to_string()), &mut model);
868 assert!(model.generating);
869 let err = crux_http::HttpError::Io("boom".to_string());
870 app.update(Event::MessageSent(Err(err)), &mut model);
871 assert!(!model.generating);
872 assert!(model.error.is_some());
876 fn cancel_generation_clears_generating() {
877 let app = OpencodeApp;
878 let mut model = Model::default();
879 model.server_url = "http://localhost:4096".to_string();
880 model.current_session_id = Some("s1".to_string());
881 app.update(Event::SendMessage("hello".to_string()), &mut model);
882 assert!(model.generating);
883 app.update(Event::CancelGeneration, &mut model);
884 assert!(!model.generating);
888 fn draft_changed_updates_model() {
889 let app = OpencodeApp;
890 let mut model = Model::default();
891 app.update(Event::DraftChanged("in progress".to_string()), &mut model);
892 assert_eq!(model.draft_message, "in progress");
893 let view = app.view(&model);
894 assert_eq!(view.draft_message, "in progress");
898 fn delete_session_optimistically_removes_from_list() {
899 let app = OpencodeApp;
900 let mut model = Model::default();
901 model.server_url = "http://localhost:4096".to_string();
902 model.sessions = vec![
903 SessionSummary { id: "s1".to_string(), title: "First".to_string() },
904 SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
906 let mut cmd = app.update(Event::DeleteSession("s1".to_string()), &mut model);
907 // Session removed optimistically
908 assert_eq!(model.sessions.len(), 1);
909 assert_eq!(model.sessions[0].id, "s2");
910 // HTTP effect emitted for the server-side delete
911 assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
915 fn delete_current_session_clears_chat_state() {
916 let app = OpencodeApp;
917 let mut model = Model::default();
918 model.server_url = "http://localhost:4096".to_string();
919 model.current_session_id = Some("s1".to_string());
920 model.current_session_title = "First".to_string();
921 model.generating = true;
922 model.messages.push(MessageView::default());
923 app.update(Event::DeleteSession("s1".to_string()), &mut model);
924 assert!(model.current_session_id.is_none());
925 assert!(model.current_session_title.is_empty());
926 assert!(model.messages.is_empty());
927 assert!(!model.generating);
931 fn auth_header_built_from_credentials() {
932 let app = OpencodeApp;
933 let mut model = Model::default();
934 model.server_url = "http://localhost:4096".to_string();
935 model.username = "opencode".to_string();
936 model.password = "secret".to_string();
937 // Verify the base64 encoding matches RFC 7617.
938 let creds = format!("{}:{}", model.username, model.password);
939 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
940 assert_eq!(encoded, "b3BlbmNvZGU6c2VjcmV0");
941 // Building a request should not panic.
942 let _ = app.get(&model, "/global/health");
946 fn crash_log_event_accumulates() {
947 let app = OpencodeApp;
948 let mut model = Model::default();
949 app.update(Event::CrashLog("boom".to_string()), &mut model);
950 assert_eq!(model.crash_logs.len(), 1);
951 let view = app.view(&model);
952 assert_eq!(view.crash_log_count, 1);
953 assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
956 /// Verify that the bincode discriminant indices the shell sends match
957 /// the Rust enum variant positions — the type generator skips
958 /// `#[facet(skip)]` variants and renumbers the remaining ones, so the
959 /// shell's index 7 (SelectSession) must land on Rust's SelectSession,
960 /// not on a skipped variant.
962 fn event_discriminants_match_generated_indices() {
963 // The generated Kotlin Event enum (see generated/.../Core.kt) maps:
964 // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
965 // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
966 // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
967 // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
968 // 15=DismissError, 16=DraftChanged, 17=CancelGeneration,
969 // 18=DeleteSession, 19=RetryMessage
971 // Serialize each Rust variant and check the leading u32 index.
972 let cases: &[(Event, u32)] = &[
974 (Event::ServerUrlChanged(String::new()), 1),
975 (Event::UsernameChanged(String::new()), 2),
976 (Event::PasswordChanged(String::new()), 3),
978 (Event::CancelAuth, 5),
979 (Event::LoadSessions, 6),
980 (Event::SelectSession(String::new()), 7),
981 (Event::CreateSession, 8),
982 (Event::LoadMessages(String::new()), 9),
983 (Event::SendMessage(String::new()), 10),
984 (Event::EventReceived(String::new()), 11),
985 (Event::NavigateToChat(String::new()), 12),
986 (Event::NavigateToSessions, 13),
987 (Event::NavigateToConnect, 14),
988 (Event::DismissError, 15),
989 (Event::DraftChanged(String::new()), 16),
990 (Event::CancelGeneration, 17),
991 (Event::DeleteSession(String::new()), 18),
992 (Event::RetryMessage(String::new()), 19),
994 for (event, expected) in cases {
995 let bytes = bincode::serialize(event).unwrap();
996 let idx = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
999 "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
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