@@ -1,137 +1,203 @@
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;
212use crux_core::{
3− App, Command,
413 macros::effect,
5− render::{RenderOperation, render},
14+ render::{render, RenderOperation},
15+ App, Command,
616};
717use crux_http::command::{Http, RequestBuilder};
818use crux_http::protocol::HttpRequest;
9−use facet::Facet;
1019use serde::{Deserialize, Serialize};
1120
12−// ─── Events ────────────────────────────────────────────────────────────────
21+use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
1322
14−#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
15−#[repr(C)]
16−pub enum Event {
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. ──────────────────────────────────────
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 ──────────────────────────────────────────────────────────────────
2127
22− // Lifecycle
28+pub enum Event {
29+ // Lifecycle / connect
2330 Start,
2431 ServerUrlChanged(String),
2532 UsernameChanged(String),
2633 PasswordChanged(String),
2734 Connect,
28− CancelAuth,
2935
30− // Session list
36+ // Sessions
3137 LoadSessions,
3238 SelectSession(String),
3339 CreateSession,
40+ DeleteSession(String),
3441
3542 // Chat
3643 LoadMessages(String),
44+ DraftChanged(String),
3745 SendMessage(String),
46+ CancelGeneration,
3847
39− // SSE event received
40− EventReceived(String),
48+ // A raw event line from the shell's `/event` SSE subscription.
49+ ServerEvent(String),
4150
42− // Navigation
43− NavigateToChat(String),
51+ // Navigation / misc
4452 NavigateToSessions,
4553 NavigateToConnect,
46−
47− // Errors
4854 DismissError,
4955
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>),
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),
8564}
8665
87−// ─── Effects ───────────────────────────────────────────────────────────────
66+// ─── Effects ─────────────────────────────────────────────────────────────────
8867
89−#[effect(facet_typegen)]
68+#[effect]
9069#[derive(Debug)]
9170pub enum Effect {
9271 Render(RenderOperation),
9372 Http(HttpRequest),
9473}
9574
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 ─────────────────────────────────────────────────────────────────
75+// ─── Model ───────────────────────────────────────────────────────────────────
10176
10277#[derive(Default)]
10378pub struct Model {
10479 server_url: String,
10580 username: String,
10681 password: String,
107− /// Set once the server replies 401 to an unauthenticated probe.
108− /// The view surfaces this so the shell can show credential fields.
82+ /// The server answered 401 to an unauthenticated probe — show credentials.
10983 auth_required: bool,
110− /// Set once a request with credentials succeeds. All subsequent
111− /// requests carry the Authorization header.
84+ /// Credentials were accepted; attach the auth header to every request.
11285 authed: bool,
11386 connected: bool,
11487 loading: bool,
11588 error: Option<String>,
116− sessions: Vec<SessionSummary>,
89+ screen: Screen,
90+
91+ sessions: Vec<SessionView>,
11792 current_session_id: Option<String>,
11893 current_session_title: String,
119− messages: Vec<MessageView>,
120− draft_message: String,
121− /// True between sending a message and the assistant reply arriving.
94+
95+ messages: Vec<MsgState>,
96+ draft: String,
12297 generating: bool,
123− /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
124− crash_logs: Vec<String>,
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+ }
125182}
126183
127−#[derive(Clone, Debug, Default)]
128−pub struct SessionSummary {
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 {
129196 pub id: String,
130197 pub title: String,
131198}
132199
133−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
134−#[repr(C)]
200+#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
135201pub enum MessageStatus {
136202 #[default]
137203 Sent,
@@ -139,18 +205,26 @@ pub enum MessageStatus {
139205 Failed,
140206}
141207
142−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
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)]
143216pub struct MessageView {
144217 pub id: String,
145218 pub role: String,
146219 pub text: String,
220+ pub reasoning: Option<String>,
221+ pub tools: Vec<ToolView>,
147222 pub time: u64,
148223 pub status: MessageStatus,
224+ pub streaming: bool,
149225}
150226
151−// ─── ViewModel ─────────────────────────────────────────────────────────────
152−
153−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
227+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
154228pub struct ViewModel {
155229 pub screen: Screen,
156230 pub server_url: String,
@@ -164,70 +238,42 @@ pub struct ViewModel {
164238 pub current_session_id: Option<String>,
165239 pub current_session_title: String,
166240 pub messages: Vec<MessageView>,
167− pub draft_message: String,
168− /// True while waiting for the assistant to reply after the user sends.
241+ pub draft: String,
169242 pub generating: bool,
170− pub crash_log_count: u32,
171− pub latest_crash_log: Option<String>,
172−}
173−
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,
181243}
182244
183−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
184−pub struct SessionView {
185− pub id: String,
186− pub title: String,
187−}
188−
189−// ─── App ───────────────────────────────────────────────────────────────────
245+// ─── App ─────────────────────────────────────────────────────────────────────
190246
191247#[derive(Default)]
192248pub struct OpencodeApp;
193249
194250impl 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.
199251 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
200252 let url = format!("{}{path}", model.server_url);
201− let req = Http::get(&url).expect_string();
202− self.with_auth(req, model)
253+ self.with_auth(Http::get(&url).expect_string(), model)
203254 }
204255
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> {
256+ fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
207257 let url = format!("{}{path}", model.server_url);
208− let req = Http::post(&url).expect_string().body_bytes(body);
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);
209263 self.with_auth(req, model)
210264 }
211265
212− /// Build a DELETE request to `path`.
213266 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
214267 let url = format!("{}{path}", model.server_url);
215− let req = Http::delete(&url).expect_string();
216− self.with_auth(req, model)
268+ self.with_auth(Http::delete(&url).expect_string(), model)
217269 }
218270
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>
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>
227273 where
228274 E: 'static,
229275 {
230− if !model.username.is_empty() || !model.password.is_empty() {
276+ if model.has_credentials() {
231277 let creds = format!("{}:{}", model.username, model.password);
232278 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
233279 req.header("Authorization", format!("Basic {encoded}"))
@@ -235,13 +281,6 @@ impl OpencodeApp {
235281 req
236282 }
237283 }
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− }
245284}
246285
247286impl App for OpencodeApp {
@@ -252,292 +291,238 @@ impl App for OpencodeApp {
252291
253292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
254293 match event {
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− }
294+ // ── Lifecycle / connect ─────────────────────────────────────────
295+ Event::Start => render(),
260296
261297 Event::ServerUrlChanged(url) => {
262− model.server_url = url;
298+ model.server_url = normalize_url(&url);
263299 render()
264300 }
265−
266301 Event::UsernameChanged(u) => {
267302 model.username = u;
268303 render()
269304 }
270−
271305 Event::PasswordChanged(p) => {
272306 model.password = p;
273307 render()
274308 }
275309
276310 Event::Connect => {
311+ if model.server_url.is_empty() {
312+ model.error = Some("Enter a server URL".to_string());
313+ return render();
314+ }
277315 model.loading = true;
278316 model.error = None;
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))
283− }
284−
285− Event::CancelAuth => {
286317 model.auth_required = false;
287− model.username.clear();
288− model.password.clear();
289− model.loading = false;
290− render()
318+ render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
291319 }
292320
321+ Event::HealthProbed(result) => self.on_health_probed(model, result),
322+
323+ // ── Sessions ────────────────────────────────────────────────────
293324 Event::LoadSessions => {
294325 model.loading = true;
295− self.get(model, "/session").build().then_send(Event::SessionsLoaded)
326+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
296327 }
297328
298329 Event::SessionsLoaded(result) => {
299330 model.loading = false;
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()
331+ match ok_body(result) {
332+ Ok(body) => {
333+ model.sessions = protocol::parse_sessions(&body)
334+ .into_iter()
335+ .map(|s| SessionView {
336+ id: s.id,
337+ title: display_title(&s.title),
338+ })
339+ .collect();
317340 }
341+ Err(e) => model.error = Some(e),
318342 }
343+ render()
319344 }
320345
321346 Event::SelectSession(id) => {
322− model.current_session_id = Some(id.clone());
323347 model.current_session_title = model
324348 .sessions
325349 .iter()
326350 .find(|s| s.id == id)
327351 .map(|s| s.title.clone())
328352 .unwrap_or_default();
329− Command::event(Event::NavigateToChat(id))
330− }
331−
332− Event::NavigateToChat(id) => {
333353 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();
340354 model.messages.clear();
355+ model.screen = Screen::Chat;
356+ model.loading = true;
357+ model.error = None;
341358 render().and(Command::event(Event::LoadMessages(id)))
342359 }
343360
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()
361+ Event::CreateSession => {
362+ model.loading = true;
363+ render().and(
364+ self.post_json(model, "/session", &serde_json::json!({}))
365+ .build()
366+ .then_send(Event::SessionCreated),
367+ )
350368 }
351369
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;
370+ Event::SessionCreated(result) => {
361371 model.loading = false;
372+ match ok_body(result) {
373+ Ok(body) => {
374+ if let Some(s) = protocol::parse_session(&body) {
375+ let view = SessionView {
376+ id: s.id.clone(),
377+ title: display_title(&s.title),
378+ };
379+ if !model.sessions.iter().any(|e| e.id == view.id) {
380+ model.sessions.insert(0, view);
381+ }
382+ return render().and(Command::event(Event::SelectSession(s.id)));
383+ }
384+ model.error = Some("Malformed session response".to_string());
385+ }
386+ Err(e) => model.error = Some(e),
387+ }
362388 render()
363389 }
364390
365− Event::CreateSession => {
366− model.loading = true;
367− self.post(model, "/session", Vec::new())
368− .build()
369− .then_send(Event::SessionCreated)
391+ Event::DeleteSession(id) => {
392+ model.sessions.retain(|s| s.id != id);
393+ let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
394+ if model.is_current(&id) {
395+ model.current_session_id = None;
396+ model.messages.clear();
397+ model.screen = Screen::Sessions;
398+ }
399+ cmd = render().and(cmd);
400+ cmd
370401 }
371402
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− }
403+ Event::SessionDeleted(result) => {
404+ if let Err(e) = ok_body(result) {
405+ model.error = Some(e);
394406 }
407+ render()
395408 }
396409
410+ // ── Chat ────────────────────────────────────────────────────────
397411 Event::LoadMessages(id) => {
398− model.loading = true;
399− let path = format!("/session/{id}/message");
400− self.get(model, &path).build().then_send(Event::MessagesLoaded)
412+ render().and(
413+ self.get(model, &format!("/session/{id}/message"))
414+ .build()
415+ .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
416+ )
401417 }
402418
403− Event::MessagesLoaded(result) => {
419+ Event::MessagesLoaded(session_id, result) => {
420+ if !model.is_current(&session_id) {
421+ return render();
422+ }
404423 model.loading = false;
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()
424+ match ok_body(result) {
425+ Ok(body) => {
426+ model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
427+ model.generating = model.messages.iter().any(|m| m.streaming);
415428 }
429+ Err(e) => model.error = Some(e),
416430 }
431+ render()
432+ }
433+
434+ Event::DraftChanged(text) => {
435+ model.draft = text;
436+ render()
417437 }
418438
419439 Event::SendMessage(text) => {
440+ let text = text.trim().to_string();
420441 let Some(session_id) = model.current_session_id.clone() else {
421442 return render();
422443 };
423− model.draft_message.clear();
444+ if text.is_empty() {
445+ return render();
446+ }
447+ model.local_seq += 1;
448+ let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
449+ user.status = MessageStatus::Pending;
450+ MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
451+ model.messages.push(user);
452+ model.draft.clear();
424453 model.generating = true;
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− }
454+ model.error = None;
455+
456+ let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
457+ render().and(
458+ self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
459+ .build()
460+ .then_send(Event::PromptSent),
461+ )
462+ }
463+
464+ Event::PromptSent(result) => {
465+ match ok_body(result) {
466+ Ok(_) => set_last_pending(model, MessageStatus::Sent),
448467 Err(e) => {
468+ set_last_pending(model, MessageStatus::Failed);
449469 model.generating = false;
450− model.error = Some(format!("Failed to send message: {e}"));
451− render()
470+ model.error = Some(e);
452471 }
453472 }
473+ render()
454474 }
455475
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()
476+ Event::CancelGeneration => {
477+ model.generating = false;
478+ match model.current_session_id.clone() {
479+ Some(id) => render().and(
480+ self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
481+ .build()
482+ .then_send(Event::Aborted),
483+ ),
484+ None => render(),
462485 }
463486 }
464487
465− Event::CrashLog(entry) => {
466− model.crash_logs.push(entry);
467− self.render_with_crashes(model)
468− }
488+ Event::Aborted(_) => render(),
469489
470− Event::DismissError => {
471− model.error = None;
472− render()
473− }
474−
475− Event::DraftChanged(text) => {
476− model.draft_message = text;
490+ // ── SSE ─────────────────────────────────────────────────────────
491+ Event::ServerEvent(json) => {
492+ if let Some(event) = protocol::parse_event(&json) {
493+ apply_server_event(model, event);
494+ }
477495 render()
478496 }
479497
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.
498+ // ── Navigation / misc ───────────────────────────────────────────
499+ Event::NavigateToSessions => {
500+ model.screen = Screen::Sessions;
501+ model.current_session_id = None;
502+ model.messages.clear();
485503 model.generating = false;
486− render()
487− }
488−
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)
504+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
501505 }
502506
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.
507+ Event::NavigateToConnect => {
508+ model.screen = Screen::Connect;
509+ model.connected = false;
510+ model.sessions.clear();
511+ model.messages.clear();
512+ model.current_session_id = None;
509513 render()
510514 }
511515
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− }
516+ Event::DismissError => {
517+ model.error = None;
518+ render()
526519 }
527520 }
528521 }
529522
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−
523+ fn view(&self, model: &Model) -> ViewModel {
539524 ViewModel {
540− screen,
525+ screen: model.screen.clone(),
541526 server_url: model.server_url.clone(),
542527 username: model.username.clone(),
543528 password: model.password.clone(),
@@ -545,459 +530,472 @@ impl App for OpencodeApp {
545530 connected: model.connected,
546531 loading: model.loading,
547532 error: model.error.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(),
533+ sessions: model.sessions.clone(),
556534 current_session_id: model.current_session_id.clone(),
557535 current_session_title: model.current_session_title.clone(),
558− messages: model.messages.clone(),
559− draft_message: model.draft_message.clone(),
536+ messages: model.messages.iter().map(MsgState::to_view).collect(),
537+ draft: model.draft.clone(),
560538 generating: model.generating,
561− crash_log_count: model.crash_logs.len() as u32,
562− latest_crash_log: model.crash_logs.last().cloned(),
563539 }
564540 }
565541}
566542
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;
543+impl OpencodeApp {
544+ fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
545+ let has_creds = model.has_credentials();
546+ match result {
547+ Ok(response) => {
548+ let status = response.status().as_u16();
549+ if status == 401 {
550+ model.loading = false;
551+ if has_creds {
552+ model.error = Some("Invalid credentials".to_string());
553+ } else {
554+ model.auth_required = true;
555+ }
556+ return render();
598557 }
599− return render();
600− }
601− if (200..300).contains(&status) {
602− if has_creds {
603− model.authed = true;
558+ if (200..300).contains(&status) {
559+ if has_creds {
560+ model.authed = true;
561+ }
562+ model.connected = true;
563+ model.auth_required = false;
564+ model.error = None;
565+ model.screen = Screen::Sessions;
566+ model.loading = true;
567+ return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
604568 }
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()
569+ model.loading = false;
570+ model.error = Some(format!("Server returned status {status}"));
571+ render()
572+ }
573+ Err(e) => {
574+ model.loading = false;
575+ model.error = Some(format!("Connection failed: {e}"));
576+ render()
577+ }
620578 }
621579 }
622580}
623581
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;
582+// ─── SSE application ─────────────────────────────────────────────────────────
583+
584+fn apply_server_event(model: &mut Model, event: ServerEvent) {
585+ match event {
586+ // Only assistant messages are created from the stream — the user's own
587+ // message is already shown optimistically, so ignoring the server's copy
588+ // avoids a duplicate bubble.
589+ ServerEvent::MessageUpdated { properties } => {
590+ let info = properties.info;
591+ if info.role != "assistant" || !model.is_current(&info.session_id) {
592+ return;
593+ }
594+ let streaming = info.time.completed.is_none() && info.error.is_none();
595+ if let Some(msg) = model.message_mut(&info.id) {
596+ msg.streaming = streaming;
597+ if msg.time == 0 {
598+ msg.time = info.time.created;
635599 }
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− });
600+ } else {
601+ let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
602+ msg.streaming = streaming;
603+ model.messages.push(msg);
604+ }
605+ if let Some(err) = info.error {
606+ model.error = Some(err.message());
607+ model.generating = false;
608+ }
609+ }
610+
611+ // Apply only to a message we already track (an assistant message from the
612+ // event above). Parts for unknown ids are ignored — see module docs.
613+ ServerEvent::MessagePartUpdated { properties } => {
614+ let part = properties.part;
615+ let delta = properties.delta;
616+ let msg_id = part.message_id().to_string();
617+ let Some(msg) = model.message_mut(&msg_id) else {
618+ return;
619+ };
620+ match part {
621+ WirePart::Text { id, text, synthetic, .. } => {
622+ if synthetic {
623+ return;
653624 }
625+ let value = pick_text(text, &delta, &msg.text_parts, &id);
626+ MsgState::upsert_part(&mut msg.text_parts, &id, value);
627+ }
628+ WirePart::Reasoning { id, text, .. } => {
629+ let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
630+ MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
631+ }
632+ WirePart::Tool { id, tool, state, .. } => {
633+ let sv = protocol::tool_state_view(&state);
634+ msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
635+ }
636+ WirePart::Other => {}
637+ }
638+ }
639+
640+ ServerEvent::MessagePartRemoved { properties } => {
641+ if let Some(msg) = model.message_mut(&properties.message_id) {
642+ msg.remove_part(&properties.part_id);
643+ }
644+ }
645+
646+ ServerEvent::MessageRemoved { properties } => {
647+ model.messages.retain(|m| m.id != properties.message_id);
648+ }
649+
650+ ServerEvent::SessionIdle { properties } => {
651+ if model.is_current(&properties.session_id) {
652+ model.generating = false;
653+ for m in &mut model.messages {
654+ m.streaming = false;
655+ }
656+ }
657+ }
658+
659+ ServerEvent::SessionError { properties } => {
660+ let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
661+ if scoped {
662+ if let Some(err) = properties.error {
663+ model.error = Some(err.message());
654664 }
665+ model.generating = false;
655666 }
656− _ => {}
657667 }
668+
669+ ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
670+ let s = properties.info;
671+ let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
672+ if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
673+ existing.title = view.title.clone();
674+ } else {
675+ model.sessions.insert(0, view);
676+ }
677+ if model.is_current(&s.id) {
678+ model.current_session_title = display_title(&s.title);
679+ }
680+ }
681+
682+ ServerEvent::SessionDeleted { properties } => {
683+ let id = properties.session_id.or(properties.info.map(|i| i.id));
684+ if let Some(id) = id {
685+ model.sessions.retain(|s| s.id != id);
686+ if model.is_current(&id) {
687+ model.current_session_id = None;
688+ model.messages.clear();
689+ model.screen = Screen::Sessions;
690+ }
691+ }
692+ }
693+
694+ ServerEvent::Other => {}
658695 }
659− sessions
660696}
661697
662−fn parse_session_id(body: &str) -> String {
663− extract_string(body, "id").unwrap_or_default()
698+// ─── Helpers ─────────────────────────────────────────────────────────────────
699+
700+/// `part.text` is the full current text when present; otherwise fall back to
701+/// accumulating the incremental `delta` onto whatever we already have.
702+fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
703+ if !text.is_empty() {
704+ return text;
705+ }
706+ let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
707+ match delta {
708+ Some(d) => format!("{prior}{d}"),
709+ None => prior,
710+ }
664711}
665712
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− }
713+fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
714+ let info = &env.info;
715+ let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
716+ msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
717+ for part in &env.parts {
718+ match part {
719+ WirePart::Text { id, text, synthetic, .. } => {
720+ if !synthetic {
721+ MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
692722 }
693723 }
694− _ => {}
724+ WirePart::Reasoning { id, text, .. } => {
725+ MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
726+ }
727+ WirePart::Tool { id, tool, state, .. } => {
728+ let sv = protocol::tool_state_view(state);
729+ msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
730+ }
731+ WirePart::Other => {}
695732 }
696− i += 1;
697733 }
698− messages
734+ msg
699735}
700736
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− }
737+fn set_last_pending(model: &mut Model, status: MessageStatus) {
738+ if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
739+ msg.status = status;
710740 }
711− extract_string(obj, "text").unwrap_or_default()
712741}
713742
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())
743+/// Trim whitespace and any trailing `/` so paths concatenate cleanly.
744+fn normalize_url(url: &str) -> String {
745+ url.trim().trim_end_matches('/').to_string()
746+}
747+
748+fn display_title(title: &str) -> String {
749+ let t = title.trim();
750+ if t.is_empty() {
751+ "Untitled".to_string()
752+ } else {
753+ t.to_string()
754+ }
724755}
725756
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()
757+/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
758+fn ok_body(result: HttpResult) -> Result<String, String> {
759+ match result {
760+ Ok(mut response) => {
761+ let status = response.status().as_u16();
762+ if (200..300).contains(&status) {
763+ Ok(response.take_body().unwrap_or_default())
764+ } else {
765+ Err(format!("Server returned status {status}"))
766+ }
767+ }
768+ Err(e) => Err(format!("Request failed: {e}")),
769+ }
736770}
737771
738−// ─── Tests ─────────────────────────────────────────────────────────────────
772+// ─── Tests ───────────────────────────────────────────────────────────────────
739773
740774#[cfg(test)]
741−mod test {
775+mod tests {
742776 use super::*;
743777
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;
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");
754− }
755−
756− #[test]
757− fn server_url_change_updates_model() {
758− let app = OpencodeApp;
759− let mut model = Model::default();
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");
778+ fn app() -> OpencodeApp {
779+ OpencodeApp::default()
767780 }
768781
769− #[test]
770− fn connect_emits_http_effect() {
771− let app = OpencodeApp;
782+ /// Put the model into a connected Chat view for the given session.
783+ fn in_chat(session_id: &str) -> Model {
772784 let mut model = Model::default();
773785 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);
786+ model.connected = true;
787+ model.screen = Screen::Chat;
788+ model.current_session_id = Some(session_id.to_string());
789+ model
778790 }
779791
780− #[test]
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");
792+ fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
793+ let _ = app.update(Event::ServerEvent(json.to_string()), model);
788794 }
789795
790796 #[test]
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");
797+ fn server_url_is_normalized() {
798+ let app = app();
799+ let mut model = Model::default();
800+ let _ = app.update(Event::ServerUrlChanged(" http://host:4096/ ".into()), &mut model);
801+ assert_eq!(app.view(&model).server_url, "http://host:4096");
795802 }
796803
797804 #[test]
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 connect_requires_a_url() {
806+ let app = app();
807+ let mut model = Model::default();
808+ let _ = app.update(Event::Connect, &mut model);
809+ assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
802810 }
803811
804812 #[test]
805− fn extract_number_parses_values() {
806− let json = r#"{"created":1234567890}"#;
807− assert_eq!(extract_number(json, "created"), Some(1234567890));
813+ fn sending_adds_optimistic_user_message_and_sets_generating() {
814+ let app = app();
815+ let mut model = in_chat("ses_1");
816+ let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
817+ let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
818+
819+ let view = app.view(&model);
820+ assert!(view.generating);
821+ assert!(view.draft.is_empty());
822+ assert_eq!(view.messages.len(), 1);
823+ assert_eq!(view.messages[0].role, "user");
824+ assert_eq!(view.messages[0].text, "hello there");
825+ assert_eq!(view.messages[0].status, MessageStatus::Pending);
808826 }
809827
810828 #[test]
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+ fn empty_send_is_ignored() {
830+ let app = app();
831+ let mut model = in_chat("ses_1");
832+ let _ = app.update(Event::SendMessage(" ".into()), &mut model);
833+ assert!(app.view(&model).messages.is_empty());
834+ assert!(!app.view(&model).generating);
829835 }
830836
831837 #[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");
838+ fn assistant_reply_streams_in_from_sse() {
839+ let app = app();
840+ let mut model = in_chat("ses_1");
841+
842+ // Assistant message shell arrives first.
843+ feed(&app, &mut model, serde_json::json!({
844+ "type": "message.updated",
845+ "properties": { "info": {
846+ "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
847+ "time": { "created": 100 }
848+ }}
849+ }));
850+ assert_eq!(app.view(&model).messages.len(), 1);
851+ assert!(app.view(&model).messages[0].streaming);
852+
853+ // Two text deltas for the same part accumulate into full text.
854+ feed(&app, &mut model, serde_json::json!({
855+ "type": "message.part.updated",
856+ "properties": { "part": {
857+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
858+ "type": "text", "text": "Hello"
859+ }}
860+ }));
861+ feed(&app, &mut model, serde_json::json!({
862+ "type": "message.part.updated",
863+ "properties": { "part": {
864+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
865+ "type": "text", "text": "Hello, world"
866+ }}
867+ }));
868+
842869 let view = app.view(&model);
843− assert_eq!(view.current_session_title, "Second");
870+ assert_eq!(view.messages[0].role, "assistant");
871+ assert_eq!(view.messages[0].text, "Hello, world");
844872 }
845873
846874 #[test]
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);
875+ fn text_delta_accumulates_when_full_text_absent() {
876+ let app = app();
877+ let mut model = in_chat("ses_1");
878+ feed(&app, &mut model, serde_json::json!({
879+ "type": "message.updated",
880+ "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
881+ }));
882+ for delta in ["Wor", "ld"] {
883+ feed(&app, &mut model, serde_json::json!({
884+ "type": "message.part.updated",
885+ "properties": {
886+ "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
887+ "delta": delta
888+ }
889+ }));
890+ }
891+ assert_eq!(app.view(&model).messages[0].text, "World");
859892 }
860893
861894 #[test]
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());
895+ fn user_message_from_sse_is_not_duplicated() {
896+ let app = app();
897+ let mut model = in_chat("ses_1");
898+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
899+ // Server echoes the user message on the stream — must be ignored.
900+ feed(&app, &mut model, serde_json::json!({
901+ "type": "message.updated",
902+ "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
903+ }));
904+ let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
905+ assert_eq!(users, 1);
873906 }
874907
875908 #[test]
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);
909+ fn session_idle_clears_generating() {
910+ let app = app();
911+ let mut model = in_chat("ses_1");
912+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
913+ assert!(app.view(&model).generating);
914+ feed(&app, &mut model, serde_json::json!({
915+ "type": "session.idle", "properties": { "sessionID": "ses_1" }
916+ }));
917+ assert!(!app.view(&model).generating);
885918 }
886919
887920 #[test]
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");
921+ fn idle_for_other_session_is_ignored() {
922+ let app = app();
923+ let mut model = in_chat("ses_1");
924+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
925+ feed(&app, &mut model, serde_json::json!({
926+ "type": "session.idle", "properties": { "sessionID": "ses_other" }
927+ }));
928+ assert!(app.view(&model).generating);
895929 }
896930
897931 #[test]
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(_))));
932+ fn reasoning_and_tool_parts_are_surfaced() {
933+ let app = app();
934+ let mut model = in_chat("ses_1");
935+ feed(&app, &mut model, serde_json::json!({
936+ "type": "message.updated",
937+ "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
938+ }));
939+ feed(&app, &mut model, serde_json::json!({
940+ "type": "message.part.updated",
941+ "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
942+ }));
943+ feed(&app, &mut model, serde_json::json!({
944+ "type": "message.part.updated",
945+ "properties": { "part": {
946+ "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
947+ "state": { "status": "completed", "title": "ls -la" }
948+ }}
949+ }));
950+ let msg = &app.view(&model).messages[0];
951+ assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
952+ assert_eq!(msg.tools.len(), 1);
953+ assert_eq!(msg.tools[0].name, "bash");
954+ assert_eq!(msg.tools[0].status, "completed");
955+ assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
912956 }
913957
914958 #[test]
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);
959+ fn session_created_event_updates_list() {
960+ let app = app();
961+ let mut model = in_chat("ses_1");
962+ feed(&app, &mut model, serde_json::json!({
963+ "type": "session.created",
964+ "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
965+ }));
966+ assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
928967 }
929968
930969 #[test]
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");
970+ fn unknown_event_types_are_ignored() {
971+ let app = app();
972+ let mut model = in_chat("ses_1");
973+ feed(&app, &mut model, serde_json::json!({
974+ "type": "pty.created", "properties": { "whatever": true }
975+ }));
976+ feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
977+ // No panic, no state change.
978+ assert!(app.view(&model).messages.is_empty());
943979 }
944980
945981 #[test]
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"));
954− }
982+ fn messages_loaded_parses_info_and_parts() {
983+ let app = app();
984+ let mut model = in_chat("ses_1");
985+ let body = serde_json::json!([
986+ { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
987+ "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
988+ { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
989+ "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
990+ ]).to_string();
991+ let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
992+ let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
955993
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− }
994+ let view = app.view(&model);
995+ assert_eq!(view.messages.len(), 2);
996+ assert_eq!(view.messages[0].text, "Question?");
997+ assert_eq!(view.messages[1].text, "Answer.");
998+ assert!(!view.messages[1].streaming);
999+ assert!(!view.generating);
10021000 }
10031001}