@@ -1,129 +1,230 @@
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>;
2125
22− // Lifecycle
26+// ─── Events ──────────────────────────────────────────────────────────────────
27+
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,
45−
46− // Errors
53+ NavigateToConnect,
4754 DismissError,
4855
49− // ── Internal events (skipped from typegen, sent only via then_send).
50− // These occupy higher discriminant indices that are never
51− // produced by the shell, so the index gap is harmless. ─────────
52−
53− #[serde(skip)]
54− #[facet(skip)]
55− SessionsLoaded(#[facet(opaque)] HttpResult<String>),
56− #[serde(skip)]
57− #[facet(skip)]
58− SessionCreated(#[facet(opaque)] HttpResult<String>),
59− #[serde(skip)]
60− #[facet(skip)]
61− MessagesLoaded(#[facet(opaque)] HttpResult<String>),
62− #[serde(skip)]
63− #[facet(skip)]
64− MessageSent(#[facet(opaque)] HttpResult<String>),
65− #[serde(skip)]
66− #[facet(skip)]
67− CrashLog(String),
68− #[serde(skip)]
69− #[facet(skip)]
70− HealthProbed(#[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),
7164}
7265
73−// ─── Effects ───────────────────────────────────────────────────────────────
66+// ─── Effects ─────────────────────────────────────────────────────────────────
7467
75−#[effect(facet_typegen)]
68+#[effect]
7669#[derive(Debug)]
7770pub enum Effect {
7871 Render(RenderOperation),
7972 Http(HttpRequest),
8073}
8174
82−// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
83−
84−pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
85−
86−// ─── Model ─────────────────────────────────────────────────────────────────
75+// ─── Model ───────────────────────────────────────────────────────────────────
8776
8877#[derive(Default)]
8978pub struct Model {
9079 server_url: String,
9180 username: String,
9281 password: String,
93− /// Set once the server replies 401 to an unauthenticated probe.
94− /// The view surfaces this so the shell can show credential fields.
82+ /// The server answered 401 to an unauthenticated probe — show credentials.
9583 auth_required: bool,
96− /// Set once a request with credentials succeeds. All subsequent
97− /// requests carry the Authorization header.
84+ /// Credentials were accepted; attach the auth header to every request.
9885 authed: bool,
9986 connected: bool,
10087 loading: bool,
10188 error: Option<String>,
102− sessions: Vec<SessionSummary>,
89+ screen: Screen,
90+
91+ sessions: Vec<SessionView>,
10392 current_session_id: Option<String>,
104− messages: Vec<MessageView>,
105− draft_message: String,
106− /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107− crash_logs: Vec<String>,
93+ current_session_title: String,
94+
95+ messages: Vec<MsgState>,
96+ draft: String,
97+ 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+ }
182+}
183+
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,
108192}
109193
110−#[derive(Clone, Debug, Default)]
111−pub struct SessionSummary {
194+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195+pub struct SessionView {
112196 pub id: String,
113197 pub title: String,
114198}
115199
116−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
200+#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
201+pub enum MessageStatus {
202+ #[default]
203+ Sent,
204+ Pending,
205+ Failed,
206+}
207+
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)]
117216pub struct MessageView {
118217 pub id: String,
119218 pub role: String,
120219 pub text: String,
220+ pub reasoning: Option<String>,
221+ pub tools: Vec<ToolView>,
121222 pub time: u64,
223+ pub status: MessageStatus,
224+ pub streaming: bool,
122225}
123226
124−// ─── ViewModel ─────────────────────────────────────────────────────────────
125−
126−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
227+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
127228pub struct ViewModel {
128229 pub screen: Screen,
129230 pub server_url: String,
@@ -135,62 +236,44 @@ pub struct ViewModel {
135236 pub error: Option<String>,
136237 pub sessions: Vec<SessionView>,
137238 pub current_session_id: Option<String>,
239+ pub current_session_title: String,
138240 pub messages: Vec<MessageView>,
139− pub draft_message: String,
140− pub crash_log_count: u32,
141− pub latest_crash_log: Option<String>,
241+ pub draft: String,
242+ pub generating: bool,
142243}
143244
144−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
145−#[repr(C)]
146−pub enum Screen {
147− #[default]
148− Connect,
149− Sessions,
150− Chat,
151−}
152−
153−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
154−pub struct SessionView {
155− pub id: String,
156− pub title: String,
157−}
158−
159−// ─── App ───────────────────────────────────────────────────────────────────
245+// ─── App ─────────────────────────────────────────────────────────────────────
160246
161247#[derive(Default)]
162248pub struct OpencodeApp;
163249
164250impl OpencodeApp {
165− /// Build a GET request to `path`, adding the Basic-Auth header when the
166− /// model has credentials. The header is always added once `authed` is true
167− /// (i.e. after the server has accepted credentials), and also during the
168− /// credential-retry attempt itself.
169251 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
170252 let url = format!("{}{path}", model.server_url);
171− let req = Http::get(&url).expect_string();
172− self.with_auth(req, model)
253+ self.with_auth(Http::get(&url).expect_string(), model)
173254 }
174255
175− /// Build a POST request to `path` with the given body bytes.
176− 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> {
177257 let url = format!("{}{path}", model.server_url);
178− 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);
179263 self.with_auth(req, model)
180264 }
181265
182− /// Attach the Basic-Auth `Authorization` header if the model carries
183− /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
184− /// `username:password`.
185− fn with_auth<E>(
186− &self,
187− req: RequestBuilder<Effect, Event, E>,
188− model: &Model,
189− ) -> RequestBuilder<Effect, Event, E>
266+ fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267+ let url = format!("{}{path}", model.server_url);
268+ self.with_auth(Http::delete(&url).expect_string(), model)
269+ }
270+
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>
190273 where
191274 E: 'static,
192275 {
193− if !model.username.is_empty() || !model.password.is_empty() {
276+ if model.has_credentials() {
194277 let creds = format!("{}:{}", model.username, model.password);
195278 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
196279 req.header("Authorization", format!("Basic {encoded}"))
@@ -198,13 +281,6 @@ impl OpencodeApp {
198281 req
199282 }
200283 }
201−
202− /// Render helper that also stamps the crash-log summary into the view.
203− fn render_with_crashes(&self, _model: &Model) -> Command<Effect, Event> {
204− // render() is the base; the view() function reads the model directly
205− // for crash-log fields, so a plain render suffices.
206− render()
207− }
208284}
209285
210286impl App for OpencodeApp {
@@ -215,193 +291,256 @@ impl App for OpencodeApp {
215291
216292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217293 match event {
218− Event::Start => {
219− model.server_url = "http://localhost:4096".to_string();
220− render()
221− }
222−
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.
223302 Event::ServerUrlChanged(url) => {
224303 model.server_url = url;
304+ if model.error.is_some() {
305+ model.error = None;
306+ }
225307 render()
226308 }
227−
228309 Event::UsernameChanged(u) => {
229310 model.username = u;
311+ if model.error.is_some() {
312+ model.error = None;
313+ }
230314 render()
231315 }
232−
233316 Event::PasswordChanged(p) => {
234317 model.password = p;
318+ if model.error.is_some() {
319+ model.error = None;
320+ }
235321 render()
236322 }
237323
238324 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+ }
239330 model.loading = true;
240331 model.error = None;
241− // Probe health. `with_auth` attaches credentials if present,
242− // so a retry after a 401 carries the Authorization header.
243− let req = self.get(model, "/global/health");
244− req.build().then_send(|result| Event::HealthProbed(result))
245− }
246−
247− Event::CancelAuth => {
248332 model.auth_required = false;
249− model.username.clear();
250− model.password.clear();
251− model.loading = false;
252− render()
333+ render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
253334 }
254335
336+ Event::HealthProbed(result) => self.on_health_probed(model, result),
337+
338+ // ── Sessions ────────────────────────────────────────────────────
255339 Event::LoadSessions => {
256340 model.loading = true;
257− self.get(model, "/session").build().then_send(Event::SessionsLoaded)
341+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
258342 }
259343
260344 Event::SessionsLoaded(result) => {
261345 model.loading = false;
262− match result {
263− Ok(mut response) => {
264− let body = response.take_body().unwrap_or_default();
265− model.sessions = parse_sessions(&body);
266− model.connected = true;
267− render()
268− }
269− Err(e) => {
270− model.error = Some(format!("Failed to load sessions: {e}"));
271− render()
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());
272358 }
359+ Err(e) => model.error = Some(e),
273360 }
361+ render()
274362 }
275363
276364 Event::SelectSession(id) => {
277− model.current_session_id = Some(id.clone());
278− Command::event(Event::NavigateToChat(id))
279− }
280−
281− Event::NavigateToChat(id) => {
365+ model.current_session_title = model
366+ .sessions
367+ .iter()
368+ .find(|s| s.id == id)
369+ .map(|s| s.title.clone())
370+ .unwrap_or_default();
282371 model.current_session_id = Some(id.clone());
283372 model.messages.clear();
373+ model.screen = Screen::Chat;
374+ model.loading = true;
375+ model.error = None;
284376 render().and(Command::event(Event::LoadMessages(id)))
285377 }
286378
287− Event::NavigateToSessions => {
288− model.current_session_id = None;
289− model.messages.clear();
290− render()
291− }
292−
293379 Event::CreateSession => {
294380 model.loading = true;
295− self.post(model, "/session", Vec::new())
296− .build()
297− .then_send(Event::SessionCreated)
381+ render().and(
382+ self.post_json(model, "/session", &serde_json::json!({}))
383+ .build()
384+ .then_send(Event::SessionCreated),
385+ )
298386 }
299387
300388 Event::SessionCreated(result) => {
301389 model.loading = false;
302− match result {
303− Ok(mut response) => {
304− let body = response.take_body().unwrap_or_default();
305− let id = parse_session_id(&body);
306− Command::event(Event::NavigateToChat(id))
307− }
308− Err(e) => {
309− model.error = Some(format!("Failed to create session: {e}"));
310− render()
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());
311403 }
404+ Err(e) => model.error = Some(e),
405+ }
406+ render()
407+ }
408+
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;
312416 }
417+ cmd = render().and(cmd);
418+ cmd
313419 }
314420
421+ Event::SessionDeleted(result) => {
422+ if let Err(e) = ok_body(result) {
423+ model.error = Some(e);
424+ }
425+ render()
426+ }
427+
428+ // ── Chat ────────────────────────────────────────────────────────
315429 Event::LoadMessages(id) => {
316− model.loading = true;
317− let path = format!("/session/{id}/message");
318− self.get(model, &path).build().then_send(Event::MessagesLoaded)
430+ render().and(
431+ self.get(model, &format!("/session/{id}/message"))
432+ .build()
433+ .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
434+ )
319435 }
320436
321− Event::MessagesLoaded(result) => {
437+ Event::MessagesLoaded(session_id, result) => {
438+ if !model.is_current(&session_id) {
439+ return render();
440+ }
322441 model.loading = false;
323− match result {
324− Ok(mut response) => {
325− let body = response.take_body().unwrap_or_default();
326− model.messages = parse_messages(&body);
327− render()
328− }
329− Err(e) => {
330− model.error = Some(format!("Failed to load messages: {e}"));
331− render()
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);
332446 }
447+ Err(e) => model.error = Some(e),
333448 }
449+ render()
450+ }
451+
452+ Event::DraftChanged(text) => {
453+ model.draft = text;
454+ render()
334455 }
335456
336457 Event::SendMessage(text) => {
458+ let text = text.trim().to_string();
337459 let Some(session_id) = model.current_session_id.clone() else {
338460 return render();
339461 };
340− model.draft_message.clear();
341− let body = serde_json::json!({
342− "sessionID": session_id,
343− "parts": [{ "type": "text", "text": text }]
344− })
345− .to_string()
346− .into_bytes();
347− let path = format!("/session/{session_id}/prompt_async");
348− self.post(model, &path, body)
349− .build()
350− .then_send(Event::MessageSent)
351− }
352−
353− Event::MessageSent(result) => {
354− match result {
355− Ok(_) => {
356− // Reload messages to pick up the assistant reply.
357− if let Some(session_id) = model.current_session_id.clone() {
358− Command::event(Event::LoadMessages(session_id))
359− } else {
360− render()
361− }
362− }
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();
471+ 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),
363485 Err(e) => {
364− model.error = Some(format!("Failed to send message: {e}"));
365− render()
486+ set_last_pending(model, MessageStatus::Failed);
487+ model.generating = false;
488+ model.error = Some(e);
366489 }
367490 }
491+ render()
492+ }
493+
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(),
503+ }
368504 }
369505
370− Event::EventReceived(_data) => {
371− // SSE event — reload messages for the current session.
372− if let Some(session_id) = model.current_session_id.clone() {
373− Command::event(Event::LoadMessages(session_id))
374− } else {
375− render()
506+ Event::Aborted(_) => render(),
507+
508+ // ── SSE ─────────────────────────────────────────────────────────
509+ Event::ServerEvent(json) => {
510+ if let Some(event) = protocol::parse_event(&json) {
511+ apply_server_event(model, event);
376512 }
513+ render()
377514 }
378515
379− Event::CrashLog(entry) => {
380− model.crash_logs.push(entry);
381− self.render_with_crashes(model)
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))
523+ }
524+
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;
531+ render()
382532 }
383533
384534 Event::DismissError => {
385535 model.error = None;
386536 render()
387537 }
388−
389− // ── Internal events (not type-generated, sent via then_send) ───
390− Event::HealthProbed(result) => handle_health_probed(self, model, result),
391538 }
392539 }
393540
394− fn view(&self, model: &Self::Model) -> Self::ViewModel {
395− let screen = if model.current_session_id.is_some() {
396− Screen::Chat
397− } else if model.connected {
398− Screen::Sessions
399− } else {
400− Screen::Connect
401− };
402−
541+ fn view(&self, model: &Model) -> ViewModel {
403542 ViewModel {
404− screen,
543+ screen: model.screen.clone(),
405544 server_url: model.server_url.clone(),
406545 username: model.username.clone(),
407546 password: model.password.clone(),
@@ -409,341 +548,504 @@ impl App for OpencodeApp {
409548 connected: model.connected,
410549 loading: model.loading,
411550 error: model.error.clone(),
412− sessions: model
413− .sessions
414− .iter()
415− .map(|s| SessionView {
416− id: s.id.clone(),
417− title: s.title.clone(),
418− })
419− .collect(),
551+ sessions: model.sessions.clone(),
420552 current_session_id: model.current_session_id.clone(),
421− messages: model.messages.clone(),
422− draft_message: model.draft_message.clone(),
423− crash_log_count: model.crash_logs.len() as u32,
424− latest_crash_log: model.crash_logs.last().cloned(),
553+ current_session_title: model.current_session_title.clone(),
554+ messages: model.messages.iter().map(MsgState::to_view).collect(),
555+ draft: model.draft.clone(),
556+ generating: model.generating,
425557 }
426558 }
427559}
428560
429−// ─── Basic-auth detection ──────────────────────────────────────────────────
430−//
431−// The opencode server uses HTTP Basic Auth when OPENCODE_SERVER_PASSWORD is set
432−// (username defaults to "opencode"). We detect this by probing /global/health
433−// without credentials:
434−//
435−// - 200 → server is open; proceed to load sessions.
436−// - 401 → server is behind basic auth; surface `auth_required` and wait for
437−// the user to supply credentials. The user then re-issues Connect,
438−// which now attaches the Authorization header to the same probe.
439−// A 200 on that retry flips `authed` and proceeds.
440−// - Other non-2xx → surface as a connection error.
441−// - Transport error → surface as a connection error.
442−
443−fn handle_health_probed(
444− app: &OpencodeApp,
445− model: &mut Model,
446− result: HttpResult<String>,
447−) -> Command<Effect, Event> {
448− let has_creds = !model.username.is_empty() || !model.password.is_empty();
449− match result {
450− Ok(response) => {
451− let status = response.status().as_u16();
452− if status == 401 { model.loading = false;
453− if has_creds {
454− // We sent credentials but the server still rejected them.
455− model.error = Some("Invalid credentials".to_string());
456− } else {
457− // No credentials were sent — server is behind basic auth.
458− // Surface the credential fields and wait for the user.
459− model.auth_required = true;
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();
460575 }
461− return render();
462− }
463− if (200..300).contains(&status) {
464− if has_creds {
465− model.authed = true;
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));
466586 }
467− model.loading = true;
468− return app
469− .get(model, "/session")
470− .build()
471− .then_send(Event::SessionsLoaded);
472− }
473− // Other status codes — treat as connection error.
474− model.loading = false;
475− model.error = Some(format!("Server returned status {status}"));
476− render()
477− }
478− Err(e) => {
479− model.loading = false;
480− model.error = Some(format!("Connection failed: {e}"));
481− render()
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()
595+ }
482596 }
483597 }
484598}
485599
486−// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
487−
488−fn parse_sessions(body: &str) -> Vec<SessionSummary> {
489− let mut sessions = Vec::new();
490− let mut depth = 0i32;
491− let mut start = 0usize;
492− for (i, ch) in body.char_indices() {
493− match ch {
494− '{' => {
495− if depth == 0 {
496− start = i;
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;
497617 }
498− depth += 1;
499− }
500− '}' => {
501− depth -= 1;
502− if depth == 0 {
503− let obj = &body[start..=i];
504− let id = extract_string(obj, "id").unwrap_or_default();
505− let title = extract_string(obj, "title").unwrap_or_default();
506− if !id.is_empty() {
507− sessions.push(SessionSummary {
508− id,
509− title: if title.is_empty() {
510− "Untitled".to_string()
511− } else {
512− title
513− },
514− });
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;
515642 }
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);
649+ }
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 => {}
655+ }
656+ }
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;
516708 }
517709 }
518− _ => {}
519710 }
711+
712+ ServerEvent::Other => {}
520713 }
521− sessions
522714}
523715
524−fn parse_session_id(body: &str) -> String {
525− extract_string(body, "id").unwrap_or_default()
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+ }
526729}
527730
528−fn parse_messages(body: &str) -> Vec<MessageView> {
529− let mut messages = Vec::new();
530− let mut depth = 0i32;
531− let mut start = 0usize;
532− let mut i = 0usize;
533− let bytes = body.as_bytes();
534− while i < body.len() {
535− let ch = bytes[i] as char;
536− match ch {
537− '{' => {
538− if depth == 0 {
539− start = i;
540− }
541− depth += 1;
542− }
543− '}' => {
544− depth -= 1;
545− if depth == 0 {
546− let obj = &body[start..=i];
547− let id = extract_string(obj, "id").unwrap_or_default();
548− let role = extract_string(obj, "role").unwrap_or_default();
549− let time = extract_number(obj, "created").unwrap_or(0);
550− if !id.is_empty() {
551− let text = extract_message_text(obj);
552− messages.push(MessageView { id, role, text, time });
553− }
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());
554740 }
555741 }
556− _ => {}
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 => {}
557750 }
558− i += 1;
559751 }
560− messages
752+ msg
561753}
562754
563−/// Extract the text from a message part. opencode messages nest the user/assistant
564−/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
565−/// first `"parts"` key.
566−fn extract_message_text(obj: &str) -> String {
567− if let Some(parts_idx) = obj.find("\"parts\"") {
568− let rest = &obj[parts_idx..];
569− if let Some(text) = extract_string(rest, "text") {
570− return text;
571− }
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;
572758 }
573− extract_string(obj, "text").unwrap_or_default()
574759}
575760
576−fn extract_string(json: &str, key: &str) -> Option<String> {
577− let pattern = format!("\"{key}\"");
578− let idx = json.find(&pattern)?;
579− let rest = &json[idx + pattern.len()..];
580− let colon = rest.find(':')?;
581− let rest = &rest[colon + 1..];
582− let quote = rest.find('"')?;
583− let rest = &rest[quote + 1..];
584− let end = rest.find('"')?;
585− Some(rest[..end].to_string())
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}")
772+ }
586773}
587774
588−fn extract_number(json: &str, key: &str) -> Option<u64> {
589− let pattern = format!("\"{key}\"");
590− let idx = json.find(&pattern)?;
591− let rest = &json[idx + pattern.len()..];
592− let colon = rest.find(':')?;
593− let rest = &rest[colon + 1..].trim_start();
594− let end = rest
595− .find(|c: char| !c.is_ascii_digit())
596− .unwrap_or(rest.len());
597− rest[..end].parse().ok()
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+ }
598782}
599783
600−// ─── Tests ─────────────────────────────────────────────────────────────────
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+ }
797+}
798+
799+// ─── Tests ───────────────────────────────────────────────────────────────────
601800
602801#[cfg(test)]
603−mod test {
802+mod tests {
604803 use super::*;
605804
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 {
811+ 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);
821+ }
822+
606823 #[test]
607− fn start_sets_default_server_url() {
608− let app = OpencodeApp;
824+ fn server_url_is_kept_raw_while_typing() {
825+ let app = app();
609826 let mut model = Model::default();
610− app.update(Event::Start, &mut model).expect_only_render();
611− assert_eq!(model.server_url, "http://localhost:4096");
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:/");
612831 }
613832
614833 #[test]
615− fn server_url_change_updates_model() {
616− let app = OpencodeApp;
834+ fn connect_normalizes_scheme_less_url() {
835+ let app = app();
617836 let mut model = Model::default();
618− app.update(Event::Start, &mut model);
619− app.update(
620− Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
621− &mut model,
622− )
623− .expect_only_render();
624− assert_eq!(model.server_url, "http://10.0.0.1:4096");
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");
625843 }
626844
627845 #[test]
628− fn connect_emits_http_effect() {
629− let app = OpencodeApp;
846+ fn connect_trims_and_keeps_explicit_scheme() {
847+ let app = app();
630848 let mut model = Model::default();
631− app.update(Event::Start, &mut model);
632− let mut cmd = app.update(Event::Connect, &mut model);
633− assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
634− assert!(model.loading);
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");
635852 }
636853
637854 #[test]
638− fn parse_sessions_extracts_id_and_title() {
639− let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
640− let sessions = parse_sessions(json);
641− assert_eq!(sessions.len(), 2);
642− assert_eq!(sessions[0].id, "abc123");
643− assert_eq!(sessions[0].title, "My Session");
644− assert_eq!(sessions[1].id, "def456");
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"));
645860 }
646861
647862 #[test]
648− fn parse_session_id_extracts_id() {
649− let json = r#"{"id":"xyz789","title":"New"}"#;
650− let id = parse_session_id(json);
651− assert_eq!(id, "xyz789");
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);
652876 }
653877
654878 #[test]
655− fn extract_string_finds_nested_keys() {
656− let json = r#"{"a":"b","id":"target","c":"d"}"#;
657− assert_eq!(extract_string(json, "id"), Some("target".to_string()));
658− assert_eq!(extract_string(json, "missing"), None);
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);
659885 }
660886
661887 #[test]
662− fn extract_number_parses_values() {
663− let json = r#"{"created":1234567890}"#;
664− assert_eq!(extract_number(json, "created"), Some(1234567890));
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+ }));
918+
919+ let view = app.view(&model);
920+ assert_eq!(view.messages[0].role, "assistant");
921+ assert_eq!(view.messages[0].text, "Hello, world");
665922 }
666923
667924 #[test]
668− fn navigate_to_sessions_clears_state() {
669− let app = OpencodeApp;
670− let mut model = Model::default();
671− model.current_session_id = Some("test".to_string());
672− model.messages.push(MessageView {
673− id: "m1".to_string(),
674− role: "user".to_string(),
675− text: "hello".to_string(),
676− time: 0,
677− });
678− app.update(Event::NavigateToSessions, &mut model);
679− assert!(model.current_session_id.is_none());
680− assert!(model.messages.is_empty());
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");
681942 }
682943
683944 #[test]
684− fn auth_header_built_from_credentials() {
685− let app = OpencodeApp;
686− let mut model = Model::default();
687− model.server_url = "http://localhost:4096".to_string();
688− model.username = "opencode".to_string();
689− model.password = "secret".to_string();
690− // Verify the base64 encoding matches RFC 7617.
691− let creds = format!("{}:{}", model.username, model.password);
692− let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
693− assert_eq!(encoded, "b3BlbmNvZGU6c2VjcmV0");
694− // Building a request should not panic.
695− let _ = app.get(&model, "/global/health");
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);
696956 }
697957
698958 #[test]
699− fn crash_log_event_accumulates() {
700− let app = OpencodeApp;
701− let mut model = Model::default();
702− app.update(Event::CrashLog("boom".to_string()), &mut model);
703− assert_eq!(model.crash_logs.len(), 1);
704− let view = app.view(&model);
705− assert_eq!(view.crash_log_count, 1);
706− assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
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);
707968 }
708969
709− /// Verify that the bincode discriminant indices the shell sends match
710− /// the Rust enum variant positions — the type generator skips
711− /// `#[facet(skip)]` variants and renumbers the remaining ones, so the
712− /// shell's index 7 (SelectSession) must land on Rust's SelectSession,
713− /// not on a skipped variant.
714970 #[test]
715− fn event_discriminants_match_generated_indices() {
716− // The generated Kotlin Event enum (see generated/.../Core.kt) maps:
717− // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718− // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719− // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720− // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
721− //
722− // Serialize each Rust variant and check the leading u32 index.
723− let cases: &[(Event, u32)] = &[
724− (Event::Start, 0),
725− (Event::ServerUrlChanged(String::new()), 1),
726− (Event::UsernameChanged(String::new()), 2),
727− (Event::PasswordChanged(String::new()), 3),
728− (Event::Connect, 4),
729− (Event::CancelAuth, 5),
730− (Event::LoadSessions, 6),
731− (Event::SelectSession(String::new()), 7),
732− (Event::CreateSession, 8),
733− (Event::LoadMessages(String::new()), 9),
734− (Event::SendMessage(String::new()), 10),
735− (Event::EventReceived(String::new()), 11),
736− (Event::NavigateToChat(String::new()), 12),
737− (Event::NavigateToSessions, 13),
738− (Event::DismissError, 14),
739− ];
740− for (event, expected) in cases {
741− let bytes = bincode::serialize(event).unwrap();
742− let idx = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
743− assert_eq!(
744− idx, *expected,
745− "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
746− );
747− }
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);
979+ }
980+
981+ #[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"));
1006+ }
1007+
1008+ #[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"));
1017+ }
1018+
1019+ #[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());
1029+ }
1030+
1031+ #[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+
1044+ 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);
7481050 }
7491051}