@@ -1,203 +1,137 @@
1−//! The Crux `App`: model, events, effects, update logic and view.
2−//!
3−//! Streaming works like this: after a successful connect the shell opens the
4−//! `/event` SSE stream and forwards every decoded event as
5−//! [`Event::ServerEvent`]. The core parses those and mutates the chat
6−//! incrementally — creating the assistant message on `message.updated`, growing
7−//! its text on `message.part.updated`, and clearing the "generating" flag on
8−//! `session.idle`. User messages are shown optimistically the moment they're
9−//! sent, so the stream only ever *adds* assistant content.
10−
111use base64::Engine;
122use crux_core::{
13− macros::effect,
14− render::{render, RenderOperation},
153 App, Command,
4+ macros::effect,
5+ render::{RenderOperation, render},
166};
177use crux_http::command::{Http, RequestBuilder};
188use crux_http::protocol::HttpRequest;
9+use facet::Facet;
1910use serde::{Deserialize, Serialize};
2011
21−use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
22−
23−/// Result of an HTTP effect handed back to the core.
24−pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
25−
26−// ─── Events ──────────────────────────────────────────────────────────────────
12+// ─── Events ────────────────────────────────────────────────────────────────
2713
14+#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
15+#[repr(C)]
2816pub enum Event {
29− // Lifecycle / connect
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. ──────────────────────────────────────
21+
22+ // Lifecycle
3023 Start,
3124 ServerUrlChanged(String),
3225 UsernameChanged(String),
3326 PasswordChanged(String),
3427 Connect,
28+ CancelAuth,
3529
36− // Sessions
30+ // Session list
3731 LoadSessions,
3832 SelectSession(String),
3933 CreateSession,
40− DeleteSession(String),
4134
4235 // Chat
4336 LoadMessages(String),
44− DraftChanged(String),
4537 SendMessage(String),
46− CancelGeneration,
4738
48− // A raw event line from the shell's `/event` SSE subscription.
49− ServerEvent(String),
39+ // SSE event received
40+ EventReceived(String),
5041
51− // Navigation / misc
42+ // Navigation
43+ NavigateToChat(String),
5244 NavigateToSessions,
5345 NavigateToConnect,
46+
47+ // Errors
5448 DismissError,
5549
56− // ── Internal: results of HTTP effects (never sent by the shell) ──────────
57− HealthProbed(HttpResult),
58− SessionsLoaded(HttpResult),
59− SessionCreated(HttpResult),
60− MessagesLoaded(String, HttpResult),
61− PromptSent(HttpResult),
62− SessionDeleted(HttpResult),
63− Aborted(HttpResult),
50+ // Draft / composer
51+ DraftChanged(String),
52+ // Cancel waiting for the assistant reply (client-side stop).
53+ CancelGeneration,
54+
55+ // Session management
56+ DeleteSession(String),
57+ // Retry sending a failed message by its (local) message id.
58+ RetryMessage(String),
59+
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. ─────────
63+
64+ #[serde(skip)]
65+ #[facet(skip)]
66+ SessionsLoaded(#[facet(opaque)] HttpResult<String>),
67+ #[serde(skip)]
68+ #[facet(skip)]
69+ SessionCreated(#[facet(opaque)] HttpResult<String>),
70+ #[serde(skip)]
71+ #[facet(skip)]
72+ MessagesLoaded(#[facet(opaque)] HttpResult<String>),
73+ #[serde(skip)]
74+ #[facet(skip)]
75+ MessageSent(#[facet(opaque)] HttpResult<String>),
76+ #[serde(skip)]
77+ #[facet(skip)]
78+ CrashLog(String),
79+ #[serde(skip)]
80+ #[facet(skip)]
81+ HealthProbed(#[facet(opaque)] HttpResult<String>),
82+ #[serde(skip)]
83+ #[facet(skip)]
84+ SessionDeleted(#[facet(opaque)] HttpResult<String>),
6485}
6586
66−// ─── Effects ─────────────────────────────────────────────────────────────────
87+// ─── Effects ───────────────────────────────────────────────────────────────
6788
68−#[effect]
89+#[effect(facet_typegen)]
6990#[derive(Debug)]
7091pub enum Effect {
7192 Render(RenderOperation),
7293 Http(HttpRequest),
7394}
7495
75−// ─── Model ───────────────────────────────────────────────────────────────────
96+// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
97+
98+pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
99+
100+// ─── Model ─────────────────────────────────────────────────────────────────
76101
77102#[derive(Default)]
78103pub struct Model {
79104 server_url: String,
80105 username: String,
81106 password: String,
82− /// The server answered 401 to an unauthenticated probe — show credentials.
107+ /// Set once the server replies 401 to an unauthenticated probe.
108+ /// The view surfaces this so the shell can show credential fields.
83109 auth_required: bool,
84− /// Credentials were accepted; attach the auth header to every request.
110+ /// Set once a request with credentials succeeds. All subsequent
111+ /// requests carry the Authorization header.
85112 authed: bool,
86113 connected: bool,
87114 loading: bool,
88115 error: Option<String>,
89− screen: Screen,
90−
91− sessions: Vec<SessionView>,
116+ sessions: Vec<SessionSummary>,
92117 current_session_id: Option<String>,
93118 current_session_title: String,
94−
95− messages: Vec<MsgState>,
96− draft: String,
119+ messages: Vec<MessageView>,
120+ draft_message: String,
121+ /// True between sending a message and the assistant reply arriving.
97122 generating: bool,
98−
99− /// Monotonic counter for optimistic (client-side) user-message ids.
100− local_seq: u64,
101−}
102−
103−impl Model {
104− fn message_mut(&mut self, id: &str) -> Option<&mut MsgState> {
105− self.messages.iter_mut().find(|m| m.id == id)
106− }
107−
108− fn has_credentials(&self) -> bool {
109− !self.username.is_empty() || !self.password.is_empty()
110− }
111−
112− fn is_current(&self, session_id: &str) -> bool {
113− self.current_session_id.as_deref() == Some(session_id)
114− }
115−}
116−
117−/// Internal per-message state. Parts are tracked by id so streaming updates can
118−/// upsert them; the [`MessageView`] handed to the shell is derived from these.
119−struct MsgState {
120− id: String,
121− role: String,
122− time: u64,
123− status: MessageStatus,
124− streaming: bool,
125− text_parts: Vec<(String, String)>,
126− reasoning_parts: Vec<(String, String)>,
127− tool_parts: Vec<(String, ToolView)>,
128−}
129−
130−impl MsgState {
131− fn new(id: String, role: String, time: u64) -> Self {
132− Self {
133− id,
134− role,
135− time,
136− status: MessageStatus::Sent,
137− streaming: false,
138− text_parts: Vec::new(),
139− reasoning_parts: Vec::new(),
140− tool_parts: Vec::new(),
141− }
142− }
143−
144− fn upsert_part(parts: &mut Vec<(String, String)>, id: &str, text: String) {
145− if let Some(slot) = parts.iter_mut().find(|(pid, _)| pid == id) {
146− slot.1 = text;
147− } else {
148− parts.push((id.to_string(), text));
149− }
150− }
151−
152− fn upsert_tool(&mut self, id: &str, view: ToolView) {
153− if let Some(slot) = self.tool_parts.iter_mut().find(|(pid, _)| pid == id) {
154− slot.1 = view;
155− } else {
156− self.tool_parts.push((id.to_string(), view));
157− }
158− }
159−
160− fn remove_part(&mut self, part_id: &str) {
161− self.text_parts.retain(|(pid, _)| pid != part_id);
162− self.reasoning_parts.retain(|(pid, _)| pid != part_id);
163− self.tool_parts.retain(|(pid, _)| pid != part_id);
164− }
165−
166− fn to_view(&self) -> MessageView {
167− let join = |parts: &[(String, String)]| {
168− parts.iter().map(|(_, t)| t.as_str()).collect::<String>()
169− };
170− let reasoning = join(&self.reasoning_parts);
171− MessageView {
172− id: self.id.clone(),
173− role: self.role.clone(),
174− text: join(&self.text_parts),
175− reasoning: (!reasoning.is_empty()).then_some(reasoning),
176− tools: self.tool_parts.iter().map(|(_, t)| t.clone()).collect(),
177− time: self.time,
178− status: self.status.clone(),
179− streaming: self.streaming,
180− }
181− }
123+ /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
124+ crash_logs: Vec<String>,
182125}
183126
184−// ─── View model ──────────────────────────────────────────────────────────────
185−
186−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
187−pub enum Screen {
188− #[default]
189− Connect,
190− Sessions,
191− Chat,
192−}
193−
194−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195−pub struct SessionView {
127+#[derive(Clone, Debug, Default)]
128+pub struct SessionSummary {
196129 pub id: String,
197130 pub title: String,
198131}
199132
200−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
133+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
134+#[repr(C)]
201135pub enum MessageStatus {
202136 #[default]
203137 Sent,
@@ -205,26 +139,18 @@ pub enum MessageStatus {
205139 Failed,
206140}
207141
208−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
209−pub struct ToolView {
210− pub name: String,
211− pub status: String,
212− pub title: Option<String>,
213−}
214−
215−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
142+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
216143pub struct MessageView {
217144 pub id: String,
218145 pub role: String,
219146 pub text: String,
220− pub reasoning: Option<String>,
221− pub tools: Vec<ToolView>,
222147 pub time: u64,
223148 pub status: MessageStatus,
224− pub streaming: bool,
225149}
226150
227−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
151+// ─── ViewModel ─────────────────────────────────────────────────────────────
152+
153+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
228154pub struct ViewModel {
229155 pub screen: Screen,
230156 pub server_url: String,
@@ -238,42 +164,70 @@ pub struct ViewModel {
238164 pub current_session_id: Option<String>,
239165 pub current_session_title: String,
240166 pub messages: Vec<MessageView>,
241− pub draft: String,
167+ pub draft_message: String,
168+ /// True while waiting for the assistant to reply after the user sends.
242169 pub generating: bool,
170+ pub crash_log_count: u32,
171+ pub latest_crash_log: Option<String>,
243172}
244173
245−// ─── App ─────────────────────────────────────────────────────────────────────
174+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
175+#[repr(C)]
176+pub enum Screen {
177+ #[default]
178+ Connect,
179+ Sessions,
180+ Chat,
181+}
182+
183+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
184+pub struct SessionView {
185+ pub id: String,
186+ pub title: String,
187+}
188+
189+// ─── App ───────────────────────────────────────────────────────────────────
246190
247191#[derive(Default)]
248192pub struct OpencodeApp;
249193
250194impl 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.
251199 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252200 let url = format!("{}{path}", model.server_url);
253− self.with_auth(Http::get(&url).expect_string(), model)
201+ let req = Http::get(&url).expect_string();
202+ self.with_auth(req, model)
254203 }
255204
256− fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
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> {
257207 let url = format!("{}{path}", model.server_url);
258− let bytes = serde_json::to_vec(body).unwrap_or_default();
259− let req = Http::post(&url)
260− .expect_string()
261− .header("Content-Type", "application/json")
262− .body_bytes(bytes);
208+ let req = Http::post(&url).expect_string().body_bytes(body);
263209 self.with_auth(req, model)
264210 }
265211
212+ /// Build a DELETE request to `path`.
266213 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267214 let url = format!("{}{path}", model.server_url);
268− self.with_auth(Http::delete(&url).expect_string(), model)
215+ let req = Http::delete(&url).expect_string();
216+ self.with_auth(req, model)
269217 }
270218
271− /// Attach an RFC 7617 Basic-Auth header when the model carries credentials.
272− fn with_auth<E>(&self, req: RequestBuilder<Effect, Event, E>, model: &Model) -> RequestBuilder<Effect, Event, E>
219+ /// Attach the Basic-Auth `Authorization` header if the model carries
220+ /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
221+ /// `username:password`.
222+ fn with_auth<E>(
223+ &self,
224+ req: RequestBuilder<Effect, Event, E>,
225+ model: &Model,
226+ ) -> RequestBuilder<Effect, Event, E>
273227 where
274228 E: 'static,
275229 {
276− if model.has_credentials() {
230+ if !model.username.is_empty() || !model.password.is_empty() {
277231 let creds = format!("{}:{}", model.username, model.password);
278232 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279233 req.header("Authorization", format!("Basic {encoded}"))
@@ -281,6 +235,13 @@ impl OpencodeApp {
281235 req
282236 }
283237 }
238+
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.
243+ render()
244+ }
284245}
285246
286247impl App for OpencodeApp {
@@ -291,256 +252,292 @@ impl App for OpencodeApp {
291252
292253 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
293254 match event {
294− // ── Lifecycle / connect ─────────────────────────────────────────
295− Event::Start => render(),
296−
297− // Store the field verbatim while typing; normalizing here would fight
298− // the user's edits (e.g. eat the "://" as they type it). The URL is
299− // normalized once, on Connect.
300− // Clear any prior error as soon as the user edits a field, so a stale
301− // message (e.g. "Invalid credentials") doesn't linger while they correct it.
255+ Event::Start => {
256+ // Don't reset server_url here — the shell restores it from
257+ // persisted storage via ServerUrlChanged before any connect.
258+ render()
259+ }
260+
302261 Event::ServerUrlChanged(url) => {
303262 model.server_url = url;
304− if model.error.is_some() {
305− model.error = None;
306− }
307263 render()
308264 }
265+
309266 Event::UsernameChanged(u) => {
310267 model.username = u;
311− if model.error.is_some() {
312− model.error = None;
313− }
314268 render()
315269 }
270+
316271 Event::PasswordChanged(p) => {
317272 model.password = p;
318− if model.error.is_some() {
319− model.error = None;
320− }
321273 render()
322274 }
323275
324276 Event::Connect => {
325− model.server_url = normalize_url(&model.server_url);
326− if model.server_url.is_empty() {
327− model.error = Some("Enter a server URL".to_string());
328− return render();
329− }
330277 model.loading = true;
331278 model.error = None;
332− model.auth_required = false;
333− render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
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))
334283 }
335284
336− Event::HealthProbed(result) => self.on_health_probed(model, result),
285+ Event::CancelAuth => {
286+ model.auth_required = false;
287+ model.username.clear();
288+ model.password.clear();
289+ model.loading = false;
290+ render()
291+ }
337292
338− // ── Sessions ────────────────────────────────────────────────────
339293 Event::LoadSessions => {
340294 model.loading = true;
341− render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
295+ self.get(model, "/session").build().then_send(Event::SessionsLoaded)
342296 }
343297
344298 Event::SessionsLoaded(result) => {
345299 model.loading = false;
346− match ok_body(result) {
347− Ok(body) => {
348− model.sessions = protocol::parse_sessions(&body)
349− .into_iter()
350− .map(|s| SessionView {
351− id: s.id,
352− title: display_title(&s.title),
353− })
354− .collect();
355− // Impose a stable alphabetical order so the list doesn't shuffle
356− // with whatever order the server happens to return sessions in.
357− model.sessions.sort_by_key(|s| s.title.to_lowercase());
300+ match result {
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();
310+ }
311+ }
312+ render()
313+ }
314+ Err(e) => {
315+ model.error = Some(format!("Failed to load sessions: {e}"));
316+ render()
358317 }
359− Err(e) => model.error = Some(e),
360318 }
361− render()
362319 }
363320
364321 Event::SelectSession(id) => {
322+ model.current_session_id = Some(id.clone());
365323 model.current_session_title = model
366324 .sessions
367325 .iter()
368326 .find(|s| s.id == id)
369327 .map(|s| s.title.clone())
370328 .unwrap_or_default();
329+ Command::event(Event::NavigateToChat(id))
330+ }
331+
332+ Event::NavigateToChat(id) => {
371333 model.current_session_id = Some(id.clone());
334+ model.current_session_title = model
335+ .sessions
336+ .iter()
337+ .find(|s| s.id == id)
338+ .map(|s| s.title.clone())
339+ .unwrap_or_default();
372340 model.messages.clear();
373− model.screen = Screen::Chat;
374− model.loading = true;
375− model.error = None;
376341 render().and(Command::event(Event::LoadMessages(id)))
377342 }
378343
379− Event::CreateSession => {
380− model.loading = true;
381− render().and(
382− self.post_json(model, "/session", &serde_json::json!({}))
383− .build()
384− .then_send(Event::SessionCreated),
385− )
344+ Event::NavigateToSessions => {
345+ model.current_session_id = None;
346+ model.current_session_title.clear();
347+ model.messages.clear();
348+ model.generating = false;
349+ render()
386350 }
387351
388− Event::SessionCreated(result) => {
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;
389361 model.loading = false;
390− match ok_body(result) {
391− Ok(body) => {
392− if let Some(s) = protocol::parse_session(&body) {
393− let view = SessionView {
394− id: s.id.clone(),
395− title: display_title(&s.title),
396− };
397− if !model.sessions.iter().any(|e| e.id == view.id) {
398− model.sessions.insert(0, view);
399− }
400− return render().and(Command::event(Event::SelectSession(s.id)));
401− }
402− model.error = Some("Malformed session response".to_string());
403− }
404− Err(e) => model.error = Some(e),
405− }
406362 render()
407363 }
408364
409− Event::DeleteSession(id) => {
410− model.sessions.retain(|s| s.id != id);
411− let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
412− if model.is_current(&id) {
413− model.current_session_id = None;
414− model.messages.clear();
415− model.screen = Screen::Sessions;
416− }
417− cmd = render().and(cmd);
418− cmd
365+ Event::CreateSession => {
366+ model.loading = true;
367+ self.post(model, "/session", Vec::new())
368+ .build()
369+ .then_send(Event::SessionCreated)
419370 }
420371
421− Event::SessionDeleted(result) => {
422− if let Err(e) = ok_body(result) {
423− model.error = Some(e);
372+ Event::SessionCreated(result) => {
373+ model.loading = false;
374+ match result {
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(
385+ 0,
386+ SessionSummary { id: id.clone(), title },
387+ );
388+ Command::event(Event::NavigateToChat(id))
389+ }
390+ Err(e) => {
391+ model.error = Some(format!("Failed to create session: {e}"));
392+ render()
393+ }
424394 }
425− render()
426395 }
427396
428− // ── Chat ────────────────────────────────────────────────────────
429397 Event::LoadMessages(id) => {
430− render().and(
431− self.get(model, &format!("/session/{id}/message"))
432− .build()
433− .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
434− )
398+ model.loading = true;
399+ let path = format!("/session/{id}/message");
400+ self.get(model, &path).build().then_send(Event::MessagesLoaded)
435401 }
436402
437− Event::MessagesLoaded(session_id, result) => {
438− if !model.is_current(&session_id) {
439− return render();
440− }
403+ Event::MessagesLoaded(result) => {
441404 model.loading = false;
442− match ok_body(result) {
443− Ok(body) => {
444− model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
445− model.generating = model.messages.iter().any(|m| m.streaming);
405+ model.generating = false;
406+ match result {
407+ Ok(mut response) => {
408+ let body = response.take_body().unwrap_or_default();
409+ model.messages = parse_messages(&body);
410+ render()
411+ }
412+ Err(e) => {
413+ model.error = Some(format!("Failed to load messages: {e}"));
414+ render()
446415 }
447− Err(e) => model.error = Some(e),
448416 }
449− render()
450− }
451−
452− Event::DraftChanged(text) => {
453− model.draft = text;
454− render()
455417 }
456418
457419 Event::SendMessage(text) => {
458− let text = text.trim().to_string();
459420 let Some(session_id) = model.current_session_id.clone() else {
460421 return render();
461422 };
462− if text.is_empty() {
463− return render();
464− }
465− model.local_seq += 1;
466− let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
467− user.status = MessageStatus::Pending;
468− MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
469− model.messages.push(user);
470− model.draft.clear();
423+ model.draft_message.clear();
471424 model.generating = true;
472− model.error = None;
473−
474− let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
475− render().and(
476− self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
477− .build()
478− .then_send(Event::PromptSent),
479− )
480− }
481−
482− Event::PromptSent(result) => {
483− match ok_body(result) {
484− Ok(_) => set_last_pending(model, MessageStatus::Sent),
425+ let body = serde_json::json!({
426+ "sessionID": session_id,
427+ "parts": [{ "type": "text", "text": text }]
428+ })
429+ .to_string()
430+ .into_bytes();
431+ let path = format!("/session/{session_id}/prompt_async");
432+ self.post(model, &path, body)
433+ .build()
434+ .then_send(Event::MessageSent)
435+ }
436+
437+ Event::MessageSent(result) => {
438+ match result {
439+ Ok(_) => {
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))
443+ } else {
444+ model.generating = false;
445+ render()
446+ }
447+ }
485448 Err(e) => {
486− set_last_pending(model, MessageStatus::Failed);
487449 model.generating = false;
488− model.error = Some(e);
450+ model.error = Some(format!("Failed to send message: {e}"));
451+ render()
489452 }
490453 }
491− render()
492454 }
493455
494− Event::CancelGeneration => {
495− model.generating = false;
496− match model.current_session_id.clone() {
497− Some(id) => render().and(
498− self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
499− .build()
500− .then_send(Event::Aborted),
501− ),
502− None => render(),
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))
460+ } else {
461+ render()
503462 }
504463 }
505464
506− Event::Aborted(_) => render(),
465+ Event::CrashLog(entry) => {
466+ model.crash_logs.push(entry);
467+ self.render_with_crashes(model)
468+ }
507469
508− // ── SSE ─────────────────────────────────────────────────────────
509− Event::ServerEvent(json) => {
510− if let Some(event) = protocol::parse_event(&json) {
511− apply_server_event(model, event);
512− }
470+ Event::DismissError => {
471+ model.error = None;
513472 render()
514473 }
515474
516− // ── Navigation / misc ───────────────────────────────────────────
517− Event::NavigateToSessions => {
518− model.screen = Screen::Sessions;
519− model.current_session_id = None;
520− model.messages.clear();
521− model.generating = false;
522− render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
475+ Event::DraftChanged(text) => {
476+ model.draft_message = text;
477+ render()
523478 }
524479
525− Event::NavigateToConnect => {
526− model.screen = Screen::Connect;
527− model.connected = false;
528− model.sessions.clear();
529− model.messages.clear();
530− model.current_session_id = None;
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;
531486 render()
532487 }
533488
534− Event::DismissError => {
535− model.error = None;
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;
498+ }
499+ let path = format!("/session/{id}");
500+ self.delete(model, &path).build().then_send(Event::SessionDeleted)
501+ }
502+
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
508+ // typegen change.
536509 render()
537510 }
511+
512+ // ── Internal events (not type-generated, sent via then_send) ───
513+ Event::HealthProbed(result) => handle_health_probed(self, model, result),
514+ Event::SessionDeleted(result) => {
515+ match result {
516+ Ok(_) => {
517+ render()
518+ }
519+ Err(e) => {
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)
524+ }
525+ }
526+ }
538527 }
539528 }
540529
541− fn view(&self, model: &Model) -> ViewModel {
530+ fn view(&self, model: &Self::Model) -> Self::ViewModel {
531+ let screen = if model.current_session_id.is_some() {
532+ Screen::Chat
533+ } else if model.connected {
534+ Screen::Sessions
535+ } else {
536+ Screen::Connect
537+ };
538+
542539 ViewModel {
543− screen: model.screen.clone(),
540+ screen,
544541 server_url: model.server_url.clone(),
545542 username: model.username.clone(),
546543 password: model.password.clone(),
@@ -548,504 +545,459 @@ impl App for OpencodeApp {
548545 connected: model.connected,
549546 loading: model.loading,
550547 error: model.error.clone(),
551− sessions: model.sessions.clone(),
548+ sessions: model
549+ .sessions
550+ .iter()
551+ .map(|s| SessionView {
552+ id: s.id.clone(),
553+ title: s.title.clone(),
554+ })
555+ .collect(),
552556 current_session_id: model.current_session_id.clone(),
553557 current_session_title: model.current_session_title.clone(),
554− messages: model.messages.iter().map(MsgState::to_view).collect(),
555− draft: model.draft.clone(),
558+ messages: model.messages.clone(),
559+ draft_message: model.draft_message.clone(),
556560 generating: model.generating,
561+ crash_log_count: model.crash_logs.len() as u32,
562+ latest_crash_log: model.crash_logs.last().cloned(),
557563 }
558564 }
559565}
560566
561−impl OpencodeApp {
562− fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
563− let has_creds = model.has_credentials();
564− match result {
565− Ok(response) => {
566− let status = response.status().as_u16();
567− if status == 401 {
568− model.loading = false;
569− if has_creds {
570− model.error = Some("Invalid credentials".to_string());
571− } else {
572− model.auth_required = true;
573− }
574− return render();
575− }
576− if (200..300).contains(&status) {
577− if has_creds {
578− model.authed = true;
579− }
580− model.connected = true;
581− model.auth_required = false;
582− model.error = None;
583− model.screen = Screen::Sessions;
584− model.loading = true;
585− return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
567+// ─── Basic-auth detection ──────────────────────────────────────────────────
568+//
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:
572+//
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.
580+
581+fn handle_health_probed(
582+ app: &OpencodeApp,
583+ model: &mut Model,
584+ result: HttpResult<String>,
585+) -> Command<Effect, Event> {
586+ let has_creds = !model.username.is_empty() || !model.password.is_empty();
587+ match result {
588+ Ok(response) => {
589+ let status = response.status().as_u16();
590+ if status == 401 { model.loading = false;
591+ if has_creds {
592+ // We sent credentials but the server still rejected them.
593+ model.error = Some("Invalid credentials".to_string());
594+ } else {
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;
586598 }
587− model.loading = false;
588− model.error = Some(format!("Server returned status {status}"));
589− render()
590− }
591− Err(e) => {
592− model.loading = false;
593− model.error = Some(format!("Connection failed: {e}"));
594− render()
599+ return render();
595600 }
601+ if (200..300).contains(&status) {
602+ if has_creds {
603+ model.authed = true;
604+ }
605+ model.loading = true;
606+ return app
607+ .get(model, "/session")
608+ .build()
609+ .then_send(Event::SessionsLoaded);
610+ }
611+ // Other status codes — treat as connection error.
612+ model.loading = false;
613+ model.error = Some(format!("Server returned status {status}"));
614+ render()
615+ }
616+ Err(e) => {
617+ model.loading = false;
618+ model.error = Some(format!("Connection failed: {e}"));
619+ render()
596620 }
597621 }
598622}
599623
600−// ─── SSE application ─────────────────────────────────────────────────────────
601−
602−fn apply_server_event(model: &mut Model, event: ServerEvent) {
603− match event {
604− // Only assistant messages are created from the stream — the user's own
605− // message is already shown optimistically, so ignoring the server's copy
606− // avoids a duplicate bubble.
607− ServerEvent::MessageUpdated { properties } => {
608− let info = properties.info;
609− if info.role != "assistant" || !model.is_current(&info.session_id) {
610− return;
611− }
612− let streaming = info.time.completed.is_none() && info.error.is_none();
613− if let Some(msg) = model.message_mut(&info.id) {
614− msg.streaming = streaming;
615− if msg.time == 0 {
616− msg.time = info.time.created;
624+// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
625+
626+fn 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() {
631+ match ch {
632+ '{' => {
633+ if depth == 0 {
634+ start = i;
617635 }
618− } else {
619− let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
620− msg.streaming = streaming;
621− model.messages.push(msg);
622− }
623− if let Some(err) = info.error {
624− model.error = Some(err.message());
625− model.generating = false;
626− }
627− }
628−
629− // Apply only to a message we already track (an assistant message from the
630− // event above). Parts for unknown ids are ignored — see module docs.
631− ServerEvent::MessagePartUpdated { properties } => {
632− let part = properties.part;
633− let delta = properties.delta;
634− let msg_id = part.message_id().to_string();
635− let Some(msg) = model.message_mut(&msg_id) else {
636− return;
637− };
638− match part {
639− WirePart::Text { id, text, synthetic, .. } => {
640− if synthetic {
641− return;
636+ depth += 1;
637+ }
638+ '}' => {
639+ depth -= 1;
640+ if depth == 0 {
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();
644+ if !id.is_empty() {
645+ sessions.push(SessionSummary {
646+ id,
647+ title: if title.is_empty() {
648+ "Untitled".to_string()
649+ } else {
650+ title
651+ },
652+ });
642653 }
643− let value = pick_text(text, &delta, &msg.text_parts, &id);
644− MsgState::upsert_part(&mut msg.text_parts, &id, value);
645− }
646− WirePart::Reasoning { id, text, .. } => {
647− let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
648− MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
649654 }
650− WirePart::Tool { id, tool, state, .. } => {
651− let sv = protocol::tool_state_view(&state);
652− msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
653− }
654− WirePart::Other => {}
655655 }
656+ _ => {}
656657 }
657−
658− ServerEvent::MessagePartRemoved { properties } => {
659− if let Some(msg) = model.message_mut(&properties.message_id) {
660− msg.remove_part(&properties.part_id);
661− }
662− }
663−
664− ServerEvent::MessageRemoved { properties } => {
665− model.messages.retain(|m| m.id != properties.message_id);
666− }
667−
668− ServerEvent::SessionIdle { properties } => {
669− if model.is_current(&properties.session_id) {
670− model.generating = false;
671− for m in &mut model.messages {
672− m.streaming = false;
673− }
674− }
675− }
676−
677− ServerEvent::SessionError { properties } => {
678− let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
679− if scoped {
680− if let Some(err) = properties.error {
681− model.error = Some(err.message());
682− }
683− model.generating = false;
684− }
685− }
686−
687− ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
688− let s = properties.info;
689− let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
690− if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
691− existing.title = view.title.clone();
692− } else {
693− model.sessions.insert(0, view);
694− }
695− if model.is_current(&s.id) {
696− model.current_session_title = display_title(&s.title);
697− }
698− }
699−
700− ServerEvent::SessionDeleted { properties } => {
701− let id = properties.session_id.or(properties.info.map(|i| i.id));
702− if let Some(id) = id {
703− model.sessions.retain(|s| s.id != id);
704− if model.is_current(&id) {
705− model.current_session_id = None;
706− model.messages.clear();
707− model.screen = Screen::Sessions;
708− }
709− }
710− }
711−
712− ServerEvent::Other => {}
713658 }
659+ sessions
714660}
715661
716−// ─── Helpers ─────────────────────────────────────────────────────────────────
717−
718−/// `part.text` is the full current text when present; otherwise fall back to
719−/// accumulating the incremental `delta` onto whatever we already have.
720−fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
721− if !text.is_empty() {
722− return text;
723− }
724− let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
725− match delta {
726− Some(d) => format!("{prior}{d}"),
727− None => prior,
728− }
662+fn parse_session_id(body: &str) -> String {
663+ extract_string(body, "id").unwrap_or_default()
729664}
730665
731−fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
732− let info = &env.info;
733− let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
734− msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
735− for part in &env.parts {
736− match part {
737− WirePart::Text { id, text, synthetic, .. } => {
738− if !synthetic {
739− MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
666+fn parse_messages(body: &str) -> Vec<MessageView> {
667+ let mut messages = Vec::new();
668+ let mut depth = 0i32;
669+ let mut start = 0usize;
670+ let mut i = 0usize;
671+ let bytes = body.as_bytes();
672+ while i < body.len() {
673+ let ch = bytes[i] as char;
674+ match ch {
675+ '{' => {
676+ if depth == 0 {
677+ start = i;
678+ }
679+ depth += 1;
680+ }
681+ '}' => {
682+ depth -= 1;
683+ if depth == 0 {
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);
688+ if !id.is_empty() {
689+ let text = extract_message_text(obj);
690+ messages.push(MessageView { id, role, text, time, status: MessageStatus::Sent });
691+ }
740692 }
741693 }
742− WirePart::Reasoning { id, text, .. } => {
743− MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
744− }
745− WirePart::Tool { id, tool, state, .. } => {
746− let sv = protocol::tool_state_view(state);
747− msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
748− }
749− WirePart::Other => {}
694+ _ => {}
750695 }
696+ i += 1;
751697 }
752− msg
753−}
754−
755−fn set_last_pending(model: &mut Model, status: MessageStatus) {
756− if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
757− msg.status = status;
758− }
698+ messages
759699}
760700
761−/// Trim whitespace and any trailing `/` so paths concatenate cleanly, and
762−/// default to an `http://` scheme when the user didn't type one (e.g.
763−/// "192.168.1.10:4096") — otherwise the shell's URL parser rejects it.
764−fn normalize_url(url: &str) -> String {
765− let trimmed = url.trim().trim_end_matches('/');
766− if trimmed.is_empty() {
767− String::new()
768− } else if trimmed.contains("://") {
769− trimmed.to_string()
770− } else {
771− format!("http://{trimmed}")
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.
704+fn 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") {
708+ return text;
709+ }
772710 }
711+ extract_string(obj, "text").unwrap_or_default()
773712}
774713
775−fn display_title(title: &str) -> String {
776− let t = title.trim();
777− if t.is_empty() {
778− "Untitled".to_string()
779− } else {
780− t.to_string()
781− }
714+fn 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())
782724}
783725
784−/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
785−fn ok_body(result: HttpResult) -> Result<String, String> {
786− match result {
787− Ok(mut response) => {
788− let status = response.status().as_u16();
789− if (200..300).contains(&status) {
790− Ok(response.take_body().unwrap_or_default())
791− } else {
792− Err(format!("Server returned status {status}"))
793− }
794− }
795− Err(e) => Err(format!("Request failed: {e}")),
796− }
726+fn 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();
732+ let end = rest
733+ .find(|c: char| !c.is_ascii_digit())
734+ .unwrap_or(rest.len());
735+ rest[..end].parse().ok()
797736}
798737
799−// ─── Tests ───────────────────────────────────────────────────────────────────
738+// ─── Tests ─────────────────────────────────────────────────────────────────
800739
801740#[cfg(test)]
802−mod tests {
741+mod test {
803742 use super::*;
804743
805− fn app() -> OpencodeApp {
806− OpencodeApp::default()
807− }
808−
809− /// Put the model into a connected Chat view for the given session.
810− fn in_chat(session_id: &str) -> Model {
744+ #[test]
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;
811750 let mut model = Model::default();
812− model.server_url = "http://localhost:4096".to_string();
813− model.connected = true;
814− model.screen = Screen::Chat;
815− model.current_session_id = Some(session_id.to_string());
816− model
817− }
818−
819− fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
820− let _ = app.update(Event::ServerEvent(json.to_string()), model);
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");
821754 }
822755
823756 #[test]
824− fn server_url_is_kept_raw_while_typing() {
825− let app = app();
757+ fn server_url_change_updates_model() {
758+ let app = OpencodeApp;
826759 let mut model = Model::default();
827− // Mid-scheme input must survive verbatim — normalizing per keystroke
828− // would eat the "://" the user is typing and make the field unusable.
829− let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
830− assert_eq!(app.view(&model).server_url, "http:/");
760+ app.update(Event::Start, &mut model);
761+ app.update(
762+ Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
763+ &mut model,
764+ )
765+ .expect_only_render();
766+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
831767 }
832768
833769 #[test]
834− fn connect_normalizes_scheme_less_url() {
835− let app = app();
770+ fn connect_emits_http_effect() {
771+ let app = OpencodeApp;
836772 let mut model = Model::default();
837− // A scheme-less host (what a user often types) must gain http:// on
838− // connect, or the shell's URL parser throws and the attempt crashes.
839− let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
840− assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
841− let _ = app.update(Event::Connect, &mut model);
842− assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
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);
843778 }
844779
845780 #[test]
846− fn connect_trims_and_keeps_explicit_scheme() {
847− let app = app();
848− let mut model = Model::default();
849− let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
850− let _ = app.update(Event::Connect, &mut model);
851− assert_eq!(app.view(&model).server_url, "https://host:4096");
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");
852788 }
853789
854790 #[test]
855− fn connect_requires_a_url() {
856− let app = app();
857− let mut model = Model::default();
858− let _ = app.update(Event::Connect, &mut model);
859− assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
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");
860795 }
861796
862797 #[test]
863− fn sending_adds_optimistic_user_message_and_sets_generating() {
864− let app = app();
865− let mut model = in_chat("ses_1");
866− let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
867− let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
868−
869− let view = app.view(&model);
870− assert!(view.generating);
871− assert!(view.draft.is_empty());
872− assert_eq!(view.messages.len(), 1);
873− assert_eq!(view.messages[0].role, "user");
874− assert_eq!(view.messages[0].text, "hello there");
875− assert_eq!(view.messages[0].status, MessageStatus::Pending);
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);
876802 }
877803
878804 #[test]
879− fn empty_send_is_ignored() {
880− let app = app();
881− let mut model = in_chat("ses_1");
882− let _ = app.update(Event::SendMessage(" ".into()), &mut model);
883− assert!(app.view(&model).messages.is_empty());
884− assert!(!app.view(&model).generating);
805+ fn extract_number_parses_values() {
806+ let json = r#"{"created":1234567890}"#;
807+ assert_eq!(extract_number(json, "created"), Some(1234567890));
885808 }
886809
887810 #[test]
888− fn assistant_reply_streams_in_from_sse() {
889− let app = app();
890− let mut model = in_chat("ses_1");
891−
892− // Assistant message shell arrives first.
893− feed(&app, &mut model, serde_json::json!({
894− "type": "message.updated",
895− "properties": { "info": {
896− "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
897− "time": { "created": 100 }
898− }}
899− }));
900− assert_eq!(app.view(&model).messages.len(), 1);
901− assert!(app.view(&model).messages[0].streaming);
902−
903− // Two text deltas for the same part accumulate into full text.
904− feed(&app, &mut model, serde_json::json!({
905− "type": "message.part.updated",
906− "properties": { "part": {
907− "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
908− "type": "text", "text": "Hello"
909− }}
910− }));
911− feed(&app, &mut model, serde_json::json!({
912− "type": "message.part.updated",
913− "properties": { "part": {
914− "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
915− "type": "text", "text": "Hello, world"
916− }}
917− }));
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(),
821+ time: 0,
822+ status: MessageStatus::Sent,
823+ });
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);
829+ }
918830
831+ #[test]
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() },
838+ ];
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");
919842 let view = app.view(&model);
920− assert_eq!(view.messages[0].role, "assistant");
921− assert_eq!(view.messages[0].text, "Hello, world");
843+ assert_eq!(view.current_session_title, "Second");
922844 }
923845
924846 #[test]
925− fn text_delta_accumulates_when_full_text_absent() {
926− let app = app();
927− let mut model = in_chat("ses_1");
928− feed(&app, &mut model, serde_json::json!({
929− "type": "message.updated",
930− "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
931− }));
932− for delta in ["Wor", "ld"] {
933− feed(&app, &mut model, serde_json::json!({
934− "type": "message.part.updated",
935− "properties": {
936− "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
937− "delta": delta
938− }
939− }));
940− }
941− assert_eq!(app.view(&model).messages[0].text, "World");
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);
942859 }
943860
944861 #[test]
945− fn user_message_from_sse_is_not_duplicated() {
946− let app = app();
947− let mut model = in_chat("ses_1");
948− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
949− // Server echoes the user message on the stream — must be ignored.
950− feed(&app, &mut model, serde_json::json!({
951− "type": "message.updated",
952− "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
953− }));
954− let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
955− assert_eq!(users, 1);
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());
956873 }
957874
958875 #[test]
959− fn session_idle_clears_generating() {
960− let app = app();
961− let mut model = in_chat("ses_1");
962− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
963− assert!(app.view(&model).generating);
964− feed(&app, &mut model, serde_json::json!({
965− "type": "session.idle", "properties": { "sessionID": "ses_1" }
966− }));
967− assert!(!app.view(&model).generating);
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);
968885 }
969886
970887 #[test]
971− fn idle_for_other_session_is_ignored() {
972− let app = app();
973− let mut model = in_chat("ses_1");
974− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
975− feed(&app, &mut model, serde_json::json!({
976− "type": "session.idle", "properties": { "sessionID": "ses_other" }
977− }));
978− assert!(app.view(&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");
979895 }
980896
981897 #[test]
982− fn reasoning_and_tool_parts_are_surfaced() {
983− let app = app();
984− let mut model = in_chat("ses_1");
985− feed(&app, &mut model, serde_json::json!({
986− "type": "message.updated",
987− "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
988− }));
989− feed(&app, &mut model, serde_json::json!({
990− "type": "message.part.updated",
991− "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
992− }));
993− feed(&app, &mut model, serde_json::json!({
994− "type": "message.part.updated",
995− "properties": { "part": {
996− "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
997− "state": { "status": "completed", "title": "ls -la" }
998− }}
999− }));
1000− let msg = &app.view(&model).messages[0];
1001− assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
1002− assert_eq!(msg.tools.len(), 1);
1003− assert_eq!(msg.tools[0].name, "bash");
1004− assert_eq!(msg.tools[0].status, "completed");
1005− assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
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() },
905+ ];
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(_))));
1006912 }
1007913
1008914 #[test]
1009− fn session_created_event_updates_list() {
1010− let app = app();
1011− let mut model = in_chat("ses_1");
1012− feed(&app, &mut model, serde_json::json!({
1013− "type": "session.created",
1014− "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
1015− }));
1016− assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
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);
1017928 }
1018929
1019930 #[test]
1020− fn unknown_event_types_are_ignored() {
1021− let app = app();
1022− let mut model = in_chat("ses_1");
1023− feed(&app, &mut model, serde_json::json!({
1024− "type": "pty.created", "properties": { "whatever": true }
1025− }));
1026− feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
1027− // No panic, no state change.
1028− assert!(app.view(&model).messages.is_empty());
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");
1029943 }
1030944
1031945 #[test]
1032− fn messages_loaded_parses_info_and_parts() {
1033− let app = app();
1034− let mut model = in_chat("ses_1");
1035− let body = serde_json::json!([
1036− { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
1037− "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
1038− { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
1039− "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
1040− ]).to_string();
1041− let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
1042− let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
1043−
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);
1044951 let view = app.view(&model);
1045− assert_eq!(view.messages.len(), 2);
1046− assert_eq!(view.messages[0].text, "Question?");
1047− assert_eq!(view.messages[1].text, "Answer.");
1048− assert!(!view.messages[1].streaming);
1049− assert!(!view.generating);
952+ assert_eq!(view.crash_log_count, 1);
953+ assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
954+ }
955+
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.
961+ #[test]
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
970+ //
971+ // Serialize each Rust variant and check the leading u32 index.
972+ let cases: &[(Event, u32)] = &[
973+ (Event::Start, 0),
974+ (Event::ServerUrlChanged(String::new()), 1),
975+ (Event::UsernameChanged(String::new()), 2),
976+ (Event::PasswordChanged(String::new()), 3),
977+ (Event::Connect, 4),
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),
993+ ];
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());
997+ assert_eq!(
998+ idx, *expected,
999+ "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
1000+ );
1001+ }
10501002 }
10511003}