1//! The Crux `App`: model, events, effects, update logic and view.
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.
14 render::{render, RenderOperation},
17use crux_http::command::{Http, RequestBuilder};
18use crux_http::protocol::HttpRequest;
19use serde::{Deserialize, Serialize};
21use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
23/// Result of an HTTP effect handed back to the core.
24pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
26// ─── Events ──────────────────────────────────────────────────────────────────
29 // Lifecycle / connect
31 ServerUrlChanged(String),
32 UsernameChanged(String),
33 PasswordChanged(String),
38 SelectSession(String),
40 DeleteSession(String),
48 // A raw event line from the shell's `/event` SSE subscription.
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),
66// ─── Effects ─────────────────────────────────────────────────────────────────
71 Render(RenderOperation),
75// ─── Model ───────────────────────────────────────────────────────────────────
82 /// The server answered 401 to an unauthenticated probe — show credentials.
84 /// Credentials were accepted; attach the auth header to every request.
88 error: Option<String>,
91 sessions: Vec<SessionView>,
92 current_session_id: Option<String>,
93 current_session_title: String,
95 messages: Vec<MsgState>,
99 /// Monotonic counter for optimistic (client-side) user-message ids.
104 fn message_mut(&mut self, id: &str) -> Option<&mut MsgState> {
105 self.messages.iter_mut().find(|m| m.id == id)
108 fn has_credentials(&self) -> bool {
109 !self.username.is_empty() || !self.password.is_empty()
112 fn is_current(&self, session_id: &str) -> bool {
113 self.current_session_id.as_deref() == Some(session_id)
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.
123 status: MessageStatus,
125 text_parts: Vec<(String, String)>,
126 reasoning_parts: Vec<(String, String)>,
127 tool_parts: Vec<(String, ToolView)>,
131 fn new(id: String, role: String, time: u64) -> Self {
136 status: MessageStatus::Sent,
138 text_parts: Vec::new(),
139 reasoning_parts: Vec::new(),
140 tool_parts: Vec::new(),
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) {
148 parts.push((id.to_string(), text));
152 fn upsert_tool(&mut self, id: &str, view: ToolView) {
153 if let Some(slot) = self.tool_parts.iter_mut().find(|(pid, _)| pid == id) {
156 self.tool_parts.push((id.to_string(), view));
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);
166 fn to_view(&self) -> MessageView {
167 let join = |parts: &[(String, String)]| {
168 parts.iter().map(|(_, t)| t.as_str()).collect::<String>()
170 let reasoning = join(&self.reasoning_parts);
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(),
178 status: self.status.clone(),
179 streaming: self.streaming,
184// ─── View model ──────────────────────────────────────────────────────────────
186#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
194#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195pub struct SessionView {
200#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
201pub enum MessageStatus {
208#[derive(Serialize, Deserialize, Clone, Debug, Default)]
212 pub title: Option<String>,
215#[derive(Serialize, Deserialize, Clone, Debug, Default)]
216pub struct MessageView {
220 pub reasoning: Option<String>,
221 pub tools: Vec<ToolView>,
223 pub status: MessageStatus,
227#[derive(Serialize, Deserialize, Clone, Debug, Default)]
228pub struct ViewModel {
230 pub server_url: String,
231 pub username: String,
232 pub password: String,
233 pub auth_required: bool,
236 pub error: Option<String>,
237 pub sessions: Vec<SessionView>,
238 pub current_session_id: Option<String>,
239 pub current_session_title: String,
240 pub messages: Vec<MessageView>,
242 pub generating: bool,
245// ─── App ─────────────────────────────────────────────────────────────────────
248pub struct OpencodeApp;
251 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252 let url = format!("{}{path}", model.server_url);
253 self.with_auth(Http::get(&url).expect_string(), model)
256 fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
257 let url = format!("{}{path}", model.server_url);
258 let bytes = serde_json::to_vec(body).unwrap_or_default();
259 let req = Http::post(&url)
261 .header("Content-Type", "application/json")
263 self.with_auth(req, model)
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)
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>
276 if model.has_credentials() {
277 let creds = format!("{}:{}", model.username, model.password);
278 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279 req.header("Authorization", format!("Basic {encoded}"))
286impl App for OpencodeApp {
289 type ViewModel = ViewModel;
290 type Effect = Effect;
292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
294 // ── Lifecycle / connect ─────────────────────────────────────────
295 Event::Start => render(),
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 Event::ServerUrlChanged(url) => {
301 model.server_url = url;
304 Event::UsernameChanged(u) => {
308 Event::PasswordChanged(p) => {
314 model.server_url = normalize_url(&model.server_url);
315 if model.server_url.is_empty() {
316 model.error = Some("Enter a server URL".to_string());
319 model.loading = true;
321 model.auth_required = false;
322 render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
325 Event::HealthProbed(result) => self.on_health_probed(model, result),
327 // ── Sessions ────────────────────────────────────────────────────
328 Event::LoadSessions => {
329 model.loading = true;
330 render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
333 Event::SessionsLoaded(result) => {
334 model.loading = false;
335 match ok_body(result) {
337 model.sessions = protocol::parse_sessions(&body)
339 .map(|s| SessionView {
341 title: display_title(&s.title),
345 Err(e) => model.error = Some(e),
350 Event::SelectSession(id) => {
351 model.current_session_title = model
354 .find(|s| s.id == id)
355 .map(|s| s.title.clone())
356 .unwrap_or_default();
357 model.current_session_id = Some(id.clone());
358 model.messages.clear();
359 model.screen = Screen::Chat;
360 model.loading = true;
362 render().and(Command::event(Event::LoadMessages(id)))
365 Event::CreateSession => {
366 model.loading = true;
368 self.post_json(model, "/session", &serde_json::json!({}))
370 .then_send(Event::SessionCreated),
374 Event::SessionCreated(result) => {
375 model.loading = false;
376 match ok_body(result) {
378 if let Some(s) = protocol::parse_session(&body) {
379 let view = SessionView {
381 title: display_title(&s.title),
383 if !model.sessions.iter().any(|e| e.id == view.id) {
384 model.sessions.insert(0, view);
386 return render().and(Command::event(Event::SelectSession(s.id)));
388 model.error = Some("Malformed session response".to_string());
390 Err(e) => model.error = Some(e),
395 Event::DeleteSession(id) => {
396 model.sessions.retain(|s| s.id != id);
397 let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
398 if model.is_current(&id) {
399 model.current_session_id = None;
400 model.messages.clear();
401 model.screen = Screen::Sessions;
403 cmd = render().and(cmd);
407 Event::SessionDeleted(result) => {
408 if let Err(e) = ok_body(result) {
409 model.error = Some(e);
414 // ── Chat ────────────────────────────────────────────────────────
415 Event::LoadMessages(id) => {
417 self.get(model, &format!("/session/{id}/message"))
419 .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
423 Event::MessagesLoaded(session_id, result) => {
424 if !model.is_current(&session_id) {
427 model.loading = false;
428 match ok_body(result) {
430 model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
431 model.generating = model.messages.iter().any(|m| m.streaming);
433 Err(e) => model.error = Some(e),
438 Event::DraftChanged(text) => {
443 Event::SendMessage(text) => {
444 let text = text.trim().to_string();
445 let Some(session_id) = model.current_session_id.clone() else {
451 model.local_seq += 1;
452 let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
453 user.status = MessageStatus::Pending;
454 MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
455 model.messages.push(user);
457 model.generating = true;
460 let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
462 self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
464 .then_send(Event::PromptSent),
468 Event::PromptSent(result) => {
469 match ok_body(result) {
470 Ok(_) => set_last_pending(model, MessageStatus::Sent),
472 set_last_pending(model, MessageStatus::Failed);
473 model.generating = false;
474 model.error = Some(e);
480 Event::CancelGeneration => {
481 model.generating = false;
482 match model.current_session_id.clone() {
483 Some(id) => render().and(
484 self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
486 .then_send(Event::Aborted),
492 Event::Aborted(_) => render(),
494 // ── SSE ─────────────────────────────────────────────────────────
495 Event::ServerEvent(json) => {
496 if let Some(event) = protocol::parse_event(&json) {
497 apply_server_event(model, event);
502 // ── Navigation / misc ───────────────────────────────────────────
503 Event::NavigateToSessions => {
504 model.screen = Screen::Sessions;
505 model.current_session_id = None;
506 model.messages.clear();
507 model.generating = false;
508 render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
511 Event::NavigateToConnect => {
512 model.screen = Screen::Connect;
513 model.connected = false;
514 model.sessions.clear();
515 model.messages.clear();
516 model.current_session_id = None;
520 Event::DismissError => {
527 fn view(&self, model: &Model) -> ViewModel {
529 screen: model.screen.clone(),
530 server_url: model.server_url.clone(),
531 username: model.username.clone(),
532 password: model.password.clone(),
533 auth_required: model.auth_required,
534 connected: model.connected,
535 loading: model.loading,
536 error: model.error.clone(),
537 sessions: model.sessions.clone(),
538 current_session_id: model.current_session_id.clone(),
539 current_session_title: model.current_session_title.clone(),
540 messages: model.messages.iter().map(MsgState::to_view).collect(),
541 draft: model.draft.clone(),
542 generating: model.generating,
548 fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
549 let has_creds = model.has_credentials();
552 let status = response.status().as_u16();
554 model.loading = false;
556 model.error = Some("Invalid credentials".to_string());
558 model.auth_required = true;
562 if (200..300).contains(&status) {
566 model.connected = true;
567 model.auth_required = false;
569 model.screen = Screen::Sessions;
570 model.loading = true;
571 return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
573 model.loading = false;
574 model.error = Some(format!("Server returned status {status}"));
578 model.loading = false;
579 model.error = Some(format!("Connection failed: {e}"));
586// ─── SSE application ─────────────────────────────────────────────────────────
588fn apply_server_event(model: &mut Model, event: ServerEvent) {
590 // Only assistant messages are created from the stream — the user's own
591 // message is already shown optimistically, so ignoring the server's copy
592 // avoids a duplicate bubble.
593 ServerEvent::MessageUpdated { properties } => {
594 let info = properties.info;
595 if info.role != "assistant" || !model.is_current(&info.session_id) {
598 let streaming = info.time.completed.is_none() && info.error.is_none();
599 if let Some(msg) = model.message_mut(&info.id) {
600 msg.streaming = streaming;
602 msg.time = info.time.created;
605 let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
606 msg.streaming = streaming;
607 model.messages.push(msg);
609 if let Some(err) = info.error {
610 model.error = Some(err.message());
611 model.generating = false;
615 // Apply only to a message we already track (an assistant message from the
616 // event above). Parts for unknown ids are ignored — see module docs.
617 ServerEvent::MessagePartUpdated { properties } => {
618 let part = properties.part;
619 let delta = properties.delta;
620 let msg_id = part.message_id().to_string();
621 let Some(msg) = model.message_mut(&msg_id) else {
625 WirePart::Text { id, text, synthetic, .. } => {
629 let value = pick_text(text, &delta, &msg.text_parts, &id);
630 MsgState::upsert_part(&mut msg.text_parts, &id, value);
632 WirePart::Reasoning { id, text, .. } => {
633 let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
634 MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
636 WirePart::Tool { id, tool, state, .. } => {
637 let sv = protocol::tool_state_view(&state);
638 msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
640 WirePart::Other => {}
644 ServerEvent::MessagePartRemoved { properties } => {
645 if let Some(msg) = model.message_mut(&properties.message_id) {
646 msg.remove_part(&properties.part_id);
650 ServerEvent::MessageRemoved { properties } => {
651 model.messages.retain(|m| m.id != properties.message_id);
654 ServerEvent::SessionIdle { properties } => {
655 if model.is_current(&properties.session_id) {
656 model.generating = false;
657 for m in &mut model.messages {
663 ServerEvent::SessionError { properties } => {
664 let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
666 if let Some(err) = properties.error {
667 model.error = Some(err.message());
669 model.generating = false;
673 ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
674 let s = properties.info;
675 let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
676 if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
677 existing.title = view.title.clone();
679 model.sessions.insert(0, view);
681 if model.is_current(&s.id) {
682 model.current_session_title = display_title(&s.title);
686 ServerEvent::SessionDeleted { properties } => {
687 let id = properties.session_id.or(properties.info.map(|i| i.id));
688 if let Some(id) = id {
689 model.sessions.retain(|s| s.id != id);
690 if model.is_current(&id) {
691 model.current_session_id = None;
692 model.messages.clear();
693 model.screen = Screen::Sessions;
698 ServerEvent::Other => {}
702// ─── Helpers ─────────────────────────────────────────────────────────────────
704/// `part.text` is the full current text when present; otherwise fall back to
705/// accumulating the incremental `delta` onto whatever we already have.
706fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
707 if !text.is_empty() {
710 let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
712 Some(d) => format!("{prior}{d}"),
717fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
718 let info = &env.info;
719 let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
720 msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
721 for part in &env.parts {
723 WirePart::Text { id, text, synthetic, .. } => {
725 MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
728 WirePart::Reasoning { id, text, .. } => {
729 MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
731 WirePart::Tool { id, tool, state, .. } => {
732 let sv = protocol::tool_state_view(state);
733 msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
735 WirePart::Other => {}
741fn set_last_pending(model: &mut Model, status: MessageStatus) {
742 if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
747/// Trim whitespace and any trailing `/` so paths concatenate cleanly, and
748/// default to an `http://` scheme when the user didn't type one (e.g.
749/// "192.168.1.10:4096") — otherwise the shell's URL parser rejects it.
750fn normalize_url(url: &str) -> String {
751 let trimmed = url.trim().trim_end_matches('/');
752 if trimmed.is_empty() {
754 } else if trimmed.contains("://") {
757 format!("http://{trimmed}")
761fn display_title(title: &str) -> String {
762 let t = title.trim();
764 "Untitled".to_string()
770/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
771fn ok_body(result: HttpResult) -> Result<String, String> {
773 Ok(mut response) => {
774 let status = response.status().as_u16();
775 if (200..300).contains(&status) {
776 Ok(response.take_body().unwrap_or_default())
778 Err(format!("Server returned status {status}"))
781 Err(e) => Err(format!("Request failed: {e}")),
785// ─── Tests ───────────────────────────────────────────────────────────────────
791 fn app() -> OpencodeApp {
792 OpencodeApp::default()
795 /// Put the model into a connected Chat view for the given session.
796 fn in_chat(session_id: &str) -> Model {
797 let mut model = Model::default();
798 model.server_url = "http://localhost:4096".to_string();
799 model.connected = true;
800 model.screen = Screen::Chat;
801 model.current_session_id = Some(session_id.to_string());
805 fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
806 let _ = app.update(Event::ServerEvent(json.to_string()), model);
810 fn server_url_is_kept_raw_while_typing() {
812 let mut model = Model::default();
813 // Mid-scheme input must survive verbatim — normalizing per keystroke
814 // would eat the "://" the user is typing and make the field unusable.
815 let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
816 assert_eq!(app.view(&model).server_url, "http:/");
820 fn connect_normalizes_scheme_less_url() {
822 let mut model = Model::default();
823 // A scheme-less host (what a user often types) must gain http:// on
824 // connect, or the shell's URL parser throws and the attempt crashes.
825 let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
826 assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
827 let _ = app.update(Event::Connect, &mut model);
828 assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
832 fn connect_trims_and_keeps_explicit_scheme() {
834 let mut model = Model::default();
835 let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
836 let _ = app.update(Event::Connect, &mut model);
837 assert_eq!(app.view(&model).server_url, "https://host:4096");
841 fn connect_requires_a_url() {
843 let mut model = Model::default();
844 let _ = app.update(Event::Connect, &mut model);
845 assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
849 fn sending_adds_optimistic_user_message_and_sets_generating() {
851 let mut model = in_chat("ses_1");
852 let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
853 let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
855 let view = app.view(&model);
856 assert!(view.generating);
857 assert!(view.draft.is_empty());
858 assert_eq!(view.messages.len(), 1);
859 assert_eq!(view.messages[0].role, "user");
860 assert_eq!(view.messages[0].text, "hello there");
861 assert_eq!(view.messages[0].status, MessageStatus::Pending);
865 fn empty_send_is_ignored() {
867 let mut model = in_chat("ses_1");
868 let _ = app.update(Event::SendMessage(" ".into()), &mut model);
869 assert!(app.view(&model).messages.is_empty());
870 assert!(!app.view(&model).generating);
874 fn assistant_reply_streams_in_from_sse() {
876 let mut model = in_chat("ses_1");
878 // Assistant message shell arrives first.
879 feed(&app, &mut model, serde_json::json!({
880 "type": "message.updated",
881 "properties": { "info": {
882 "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
883 "time": { "created": 100 }
886 assert_eq!(app.view(&model).messages.len(), 1);
887 assert!(app.view(&model).messages[0].streaming);
889 // Two text deltas for the same part accumulate into full text.
890 feed(&app, &mut model, serde_json::json!({
891 "type": "message.part.updated",
892 "properties": { "part": {
893 "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
894 "type": "text", "text": "Hello"
897 feed(&app, &mut model, serde_json::json!({
898 "type": "message.part.updated",
899 "properties": { "part": {
900 "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
901 "type": "text", "text": "Hello, world"
905 let view = app.view(&model);
906 assert_eq!(view.messages[0].role, "assistant");
907 assert_eq!(view.messages[0].text, "Hello, world");
911 fn text_delta_accumulates_when_full_text_absent() {
913 let mut model = in_chat("ses_1");
914 feed(&app, &mut model, serde_json::json!({
915 "type": "message.updated",
916 "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
918 for delta in ["Wor", "ld"] {
919 feed(&app, &mut model, serde_json::json!({
920 "type": "message.part.updated",
922 "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
927 assert_eq!(app.view(&model).messages[0].text, "World");
931 fn user_message_from_sse_is_not_duplicated() {
933 let mut model = in_chat("ses_1");
934 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
935 // Server echoes the user message on the stream — must be ignored.
936 feed(&app, &mut model, serde_json::json!({
937 "type": "message.updated",
938 "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
940 let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
941 assert_eq!(users, 1);
945 fn session_idle_clears_generating() {
947 let mut model = in_chat("ses_1");
948 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
949 assert!(app.view(&model).generating);
950 feed(&app, &mut model, serde_json::json!({
951 "type": "session.idle", "properties": { "sessionID": "ses_1" }
953 assert!(!app.view(&model).generating);
957 fn idle_for_other_session_is_ignored() {
959 let mut model = in_chat("ses_1");
960 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
961 feed(&app, &mut model, serde_json::json!({
962 "type": "session.idle", "properties": { "sessionID": "ses_other" }
964 assert!(app.view(&model).generating);
968 fn reasoning_and_tool_parts_are_surfaced() {
970 let mut model = in_chat("ses_1");
971 feed(&app, &mut model, serde_json::json!({
972 "type": "message.updated",
973 "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
975 feed(&app, &mut model, serde_json::json!({
976 "type": "message.part.updated",
977 "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
979 feed(&app, &mut model, serde_json::json!({
980 "type": "message.part.updated",
981 "properties": { "part": {
982 "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
983 "state": { "status": "completed", "title": "ls -la" }
986 let msg = &app.view(&model).messages[0];
987 assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
988 assert_eq!(msg.tools.len(), 1);
989 assert_eq!(msg.tools[0].name, "bash");
990 assert_eq!(msg.tools[0].status, "completed");
991 assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
995 fn session_created_event_updates_list() {
997 let mut model = in_chat("ses_1");
998 feed(&app, &mut model, serde_json::json!({
999 "type": "session.created",
1000 "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
1002 assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
1006 fn unknown_event_types_are_ignored() {
1008 let mut model = in_chat("ses_1");
1009 feed(&app, &mut model, serde_json::json!({
1010 "type": "pty.created", "properties": { "whatever": true }
1012 feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
1013 // No panic, no state change.
1014 assert!(app.view(&model).messages.is_empty());
1018 fn messages_loaded_parses_info_and_parts() {
1020 let mut model = in_chat("ses_1");
1021 let body = serde_json::json!([
1022 { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
1023 "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
1024 { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
1025 "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
1027 let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
1028 let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
1030 let view = app.view(&model);
1031 assert_eq!(view.messages.len(), 2);
1032 assert_eq!(view.messages[0].text, "Question?");
1033 assert_eq!(view.messages[1].text, "Answer.");
1034 assert!(!view.messages[1].streaming);
1035 assert!(!view.generating);
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list