From 3500b7d9b5068ee2ddfb673840afafdea3e30868 Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Sun, 9 Aug 2026 23:34:38 +0100 Subject: [PATCH 1/2] refactor: complete internal command bus migration --- src/acp_client.rs | 129 +++++++------- src/acp_state.rs | 65 ++++--- src/command.rs | 114 +++++++++++++ src/handlers.rs | 285 ++++++++++++++++--------------- src/mesh.rs | 10 +- src/protocol.rs | 345 ++++++++------------------------------ src/runtime/connection.rs | 4 +- src/runtime/event_loop.rs | 59 ++----- src/runtime/mod.rs | 222 ++++++++++++------------ 9 files changed, 581 insertions(+), 652 deletions(-) diff --git a/src/acp_client.rs b/src/acp_client.rs index 5afae0f..215b610 100644 --- a/src/acp_client.rs +++ b/src/acp_client.rs @@ -19,6 +19,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use crate::ServerChannelMsg; use crate::acp_state::{AcpAppEvent, AcpModelsMetaInfo, AcpSessionUpdate}; +use crate::command::{Command, PromptBlock, SessionListRequest}; use crate::domain::auth::{OAuthFlow, OAuthResult, OAuthResultStatus}; use crate::domain::model::ModelEntry; use crate::domain::profile::{AgentInfo, ProfileInfo}; @@ -27,9 +28,9 @@ use crate::domain::session::{ UndoStackSnapshot, }; use crate::protocol::{ - AuthProvidersData, ClientMsg, MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, - OAuthFlowDto, OAuthResultDto, RedoResultData, RemoteSessionAttachInfo, RemoteSessionListInfo, - SessionListRequest, UndoResultData, UndoStackFrame, + AuthProvidersData, MeshInviteCreatedInfo, MeshNodesInfo, MeshStatusInfo, OAuthFlowDto, + OAuthResultDto, RedoResultData, RemoteSessionAttachInfo, RemoteSessionListInfo, UndoResultData, + UndoStackFrame, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -539,7 +540,7 @@ pub async fn probe_websocket(url: &str, timeout: Duration) -> bool { pub(crate) async fn run_websocket_agent( url: String, - cmd_rx: &mut mpsc::UnboundedReceiver, + cmd_rx: &mut mpsc::UnboundedReceiver, srv_tx: mpsc::UnboundedSender, conn_tx: mpsc::UnboundedSender, launch_cwd: Option, @@ -611,7 +612,7 @@ pub(crate) async fn run_websocket_agent( let Some(cmd) = cmd else { break Ok(()); }; - if let Err(err) = handle_client_msg(&connection, &state, &srv_tx, cmd).await { + if let Err(err) = handle_command(&connection, &state, &srv_tx, cmd).await { send_error(&srv_tx, format!("ACP request failed: {err:?}")); } } @@ -821,7 +822,7 @@ async fn handle_ws_elicitation_request( pub(crate) async fn run_stdio_agent( agent: AcpAgent, - cmd_rx: &mut mpsc::UnboundedReceiver, + cmd_rx: &mut mpsc::UnboundedReceiver, srv_tx: mpsc::UnboundedSender, conn_tx: mpsc::UnboundedSender, launch_cwd: Option, @@ -883,7 +884,7 @@ pub(crate) async fn run_stdio_agent( )); while let Some(cmd) = cmd_rx.recv().await { - if let Err(err) = handle_client_msg(&connection, &state, &srv_tx, cmd).await { + if let Err(err) = handle_command(&connection, &state, &srv_tx, cmd).await { send_error(&srv_tx, format!("ACP request failed: {err:?}")); } } @@ -893,14 +894,14 @@ pub(crate) async fn run_stdio_agent( .await } -async fn handle_client_msg( +async fn handle_command( connection: &C, state: &Arc, srv_tx: &mpsc::UnboundedSender, - cmd: ClientMsg, + cmd: Command, ) -> Result<(), acp_sdk::Error> { match cmd { - ClientMsg::Init => { + Command::Init => { let response = connection .request( acp::InitializeRequest::new(ProtocolVersion::V1) @@ -926,14 +927,9 @@ async fn handle_client_msg( ); post_connect_diagnostics(connection, srv_tx).await; } - ClientMsg::ListSessions { - request, - cursor, - cwd, - .. - } => { + Command::ListSessions { request, cursor } => { let mut req = acp::ListSessionsRequest::new().cursor(cursor); - if let Some(cwd) = cwd.as_deref() { + if let Some(cwd) = request.cwd() { req = req.cwd(PathBuf::from(cwd)); } match connection.request(req).await { @@ -947,9 +943,7 @@ async fn handle_client_msg( ), } } - ClientMsg::NewSession { - cwd, profile_id, .. - } => { + Command::NewSession { cwd, profile_id } => { let mut req = acp::NewSessionRequest::new( cwd.map(PathBuf::from) .unwrap_or_else(|| state.default_cwd()), @@ -973,7 +967,7 @@ async fn handle_client_msg( send_config_updates(state, srv_tx, config_options).await; } } - ClientMsg::LoadSession { session_id, cwd } => { + Command::LoadSession { session_id, cwd } => { state.set_current_session_id(session_id.clone()).await; state.begin_loading(&session_id).await; let load_cwd = load_session_cwd(cwd.as_deref(), state.default_cwd()); @@ -1033,7 +1027,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::UndoStack(undo_stack)); } } - ClientMsg::Prompt { prompt, local_id } => { + Command::Prompt { prompt, local_id } => { let Some(session_id) = state.current_session_id().await else { send_error(srv_tx, "cannot prompt before a session is loaded"); return Ok(()); @@ -1068,7 +1062,7 @@ async fn handle_client_msg( Ok(()) })?; } - ClientMsg::CancelSession => { + Command::CancelSession => { let Some(session_id) = state.current_session_id().await else { send_acp( srv_tx, @@ -1088,15 +1082,15 @@ async fn handle_client_msg( }, ); } - ClientMsg::DeleteSession { session_id } => { + Command::DeleteSession { session_id } => { connection .request(acp::DeleteSessionRequest::new(session_id)) .await?; } - ClientMsg::SetAgentMode { mode } => { + Command::SetAgentMode { mode } => { set_config_option(connection, state, srv_tx, "mode", &mode, None).await?; } - ClientMsg::SetReasoningEffort { reasoning_effort } => { + Command::SetReasoningEffort { reasoning_effort } => { set_config_option( connection, state, @@ -1107,7 +1101,7 @@ async fn handle_client_msg( ) .await?; } - ClientMsg::SetSessionModel { + Command::SetSessionModel { session_id, model_id, node_id, @@ -1141,7 +1135,7 @@ async fn handle_client_msg( send_provider_changed(srv_tx, &model); send_config_updates(state, srv_tx, response.config_options).await; } - ClientMsg::ListAllModels { refresh } => { + Command::ListAllModels { refresh } => { let response = load_acp_models(connection, refresh).await?; state.set_models(response.models.clone()).await; send_models(srv_tx, &response); @@ -1150,7 +1144,7 @@ async fn handle_client_msg( send_provider_changed(srv_tx, &model); } } - ClientMsg::ListProfiles => match load_acp_profiles(connection).await { + Command::ListProfiles => match load_acp_profiles(connection).await { Ok(response) => send_profiles(srv_tx, response), Err(err) => send_acp( srv_tx, @@ -1160,7 +1154,7 @@ async fn handle_client_msg( }, ), }, - ClientMsg::ListProfileAgents { profile_id } => { + Command::ListProfileAgents { profile_id } => { match load_acp_profile_agents(connection, &profile_id).await { Ok(response) => send_acp( srv_tx, @@ -1178,7 +1172,7 @@ async fn handle_client_msg( ), } } - ClientMsg::SetDelegateModel { + Command::SetDelegateModel { session_id, agent_id, model_id, @@ -1206,7 +1200,7 @@ async fn handle_client_msg( }, ); } - ClientMsg::ListAuthProviders => { + Command::ListAuthProviders => { let response = call_querymt_ext(connection, "querymt/auth/status", json!({})).await?; if let Ok(auth) = serde_json::from_value::(ext_payload(&response).clone()) @@ -1214,7 +1208,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::AuthProviders(auth.providers)); } } - ClientMsg::StartOAuthLogin { provider } => { + Command::StartOAuthLogin { provider } => { let response = call_querymt_ext( connection, "querymt/auth/start", @@ -1229,7 +1223,7 @@ async fn handle_client_msg( ); } } - ClientMsg::CompleteOAuthLogin { flow_id, response } => { + Command::CompleteOAuthLogin { flow_id, response } => { let response = call_querymt_ext( connection, "querymt/auth/complete", @@ -1245,7 +1239,7 @@ async fn handle_client_msg( ); } } - ClientMsg::DisconnectOAuth { provider } => { + Command::DisconnectOAuth { provider } => { let response = call_querymt_ext( connection, "querymt/auth/logout", @@ -1261,14 +1255,14 @@ async fn handle_client_msg( ); } } - ClientMsg::ElicitationResponse { + Command::ElicitationResponse { elicitation_id, action, content, } => { respond_to_elicitation(state, &elicitation_id, &action, content).await; } - ClientMsg::ForkSession { message_id } => { + Command::ForkSession { message_id } => { let Some(session_id) = state.current_session_id().await else { send_acp( srv_tx, @@ -1299,9 +1293,8 @@ async fn handle_client_msg( ), } } - ClientMsg::SubscribeSession { .. } => {} - ClientMsg::GetAgentMode => {} - ClientMsg::GetFileIndex => { + Command::SubscribeSession { .. } => {} + Command::GetFileIndex => { // TODO(ACP parity): replace the deprecated UI file-index endpoint with // an ACP/QueryMT extension or client-side workspace indexing. send_error( @@ -1309,7 +1302,7 @@ async fn handle_client_msg( "file mentions are not exposed in the ACP subset yet", ); } - ClientMsg::Undo { message_id } => { + Command::Undo { message_id } => { let Some(session_id) = state.current_session_id().await else { send_error(srv_tx, "cannot undo before a session is loaded"); return Ok(()); @@ -1329,7 +1322,7 @@ async fn handle_client_msg( ); } } - ClientMsg::Redo => { + Command::Redo => { let Some(session_id) = state.current_session_id().await else { send_error(srv_tx, "cannot redo before a session is loaded"); return Ok(()); @@ -1349,7 +1342,7 @@ async fn handle_client_msg( ); } } - ClientMsg::ListRemoteNodes => { + Command::ListRemoteNodes => { let status_resp = call_querymt_ext(connection, "querymt/mesh/status", json!({})).await?; if let Ok(status) = @@ -1364,7 +1357,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::MeshNodes(nodes)); } } - ClientMsg::ListRemoteSessions { + Command::ListRemoteSessions { node_id, offset, limit, @@ -1372,7 +1365,7 @@ async fn handle_client_msg( let response = call_querymt_ext( connection, "querymt/remote/sessions", - json!({ "node_id": node_id, "offset": offset.unwrap_or(0), "limit": limit.unwrap_or(50) }), + json!({ "node_id": node_id, "offset": offset, "limit": limit }), ) .await?; if let Ok(list) = @@ -1381,7 +1374,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::RemoteSessions(list)); } } - ClientMsg::CreateRemoteSession { node_id, cwd, .. } => { + Command::CreateRemoteSession { node_id, cwd } => { let response = call_querymt_ext( connection, "querymt/remote/createSession", @@ -1394,7 +1387,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::RemoteSessionAttached(attached)); } } - ClientMsg::AttachRemoteSession { + Command::AttachRemoteSession { node_id, session_id, } => { @@ -1410,7 +1403,7 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::RemoteSessionAttached(attached)); } } - ClientMsg::CreateMeshInvite { + Command::CreateMeshInvite { mesh_name, ttl, max_uses, @@ -1427,11 +1420,10 @@ async fn handle_client_msg( send_acp(srv_tx, AcpAppEvent::MeshInviteCreated(invite)); } } - ClientMsg::ListSessionChildren { .. } - | ClientMsg::DismissRemoteSession { .. } - | ClientMsg::SetApiToken { .. } - | ClientMsg::ClearApiToken { .. } - | ClientMsg::SetAuthMethod { .. } => { + Command::ListSessionChildren { .. } + | Command::DismissRemoteSession { .. } + | Command::SetApiToken { .. } + | Command::ClearApiToken { .. } => { // TODO(ACP parity): these actions relied on QueryMT UI-API-only // methods. Keep them explicit instead of silently falling back. send_error( @@ -1667,14 +1659,12 @@ fn client_capabilities() -> acp::ClientCapabilities { ) } -fn prompt_blocks(blocks: Vec) -> Vec { +fn prompt_blocks(blocks: Vec) -> Vec { blocks .into_iter() .map(|block| match block { - crate::protocol::PromptBlock::Text { text } => { - acp::ContentBlock::Text(acp::TextContent::new(text)) - } - crate::protocol::PromptBlock::ResourceLink { name, uri } => { + PromptBlock::Text { text } => acp::ContentBlock::Text(acp::TextContent::new(text)), + PromptBlock::ResourceLink { name, uri } => { acp::ContentBlock::ResourceLink(acp::ResourceLink::new(name, uri)) } }) @@ -2914,6 +2904,29 @@ mod tests { .collect() } + #[test] + fn prompt_blocks_convert_semantic_text_and_resource_links() { + let blocks = prompt_blocks(vec![ + PromptBlock::Text { + text: "inspect this".into(), + }, + PromptBlock::ResourceLink { + name: "main.rs".into(), + uri: "file:///repo/src/main.rs".into(), + }, + ]); + + assert!(matches!( + blocks.as_slice(), + [ + acp::ContentBlock::Text(text), + acp::ContentBlock::ResourceLink(link), + ] if text.text == "inspect this" + && link.name == "main.rs" + && link.uri == "file:///repo/src/main.rs" + )); + } + #[test] fn oauth_flow_from_wire_preserves_semantic_fields() { let flow = oauth_flow_from_wire(OAuthFlowDto { diff --git a/src/acp_state.rs b/src/acp_state.rs index 90db134..b19c454 100644 --- a/src/acp_state.rs +++ b/src/acp_state.rs @@ -386,10 +386,12 @@ impl crate::app::App { self.streaming_cache.invalidate(); self.set_status(LogLevel::Info, "session", "undone - reloading session"); if let Some(ref sid) = self.session_id { - return vec![Command::LoadSession { - session_id: sid.clone(), - cwd: self.current_session_cwd(), - }]; + return Command::load_session_commands( + sid.clone(), + self.current_session_cwd(), + self.agent_id.clone(), + ) + .into(); } } UndoResult::Rejected { @@ -421,10 +423,12 @@ impl crate::app::App { self.build_undo_state_from_server_stack(&stack, None, None); self.set_status(LogLevel::Info, "session", "redone - reloading session"); if let Some(ref sid) = self.session_id { - return vec![Command::LoadSession { - session_id: sid.clone(), - cwd: self.current_session_cwd(), - }]; + return Command::load_session_commands( + sid.clone(), + self.current_session_cwd(), + self.agent_id.clone(), + ) + .into(); } } RedoResult::Rejected { message, stack } => { @@ -449,16 +453,12 @@ impl crate::app::App { } => { self.popup = Popup::None; self.set_status(LogLevel::Info, "fork", "forked - loading session"); - return vec![ - Command::LoadSession { - session_id: forked_session_id.clone(), - cwd: self.current_session_cwd(), - }, - Command::SubscribeSession { - session_id: forked_session_id, - agent_id: self.agent_id.clone(), - }, - ]; + return Command::load_session_commands( + forked_session_id, + self.current_session_cwd(), + self.agent_id.clone(), + ) + .into(); } ForkResult::Succeeded { source_session_id: _, @@ -2532,8 +2532,9 @@ mod tests { } #[test] - fn native_remote_attach_loads_attached_session() { + fn native_remote_attach_loads_and_subscribes_once() { let mut app = App::new(); + app.agent_id = Some("agent-1".into()); let replies = app.handle_acp_event(AcpAppEvent::RemoteSessionAttached( RemoteSessionAttachInfo { @@ -2548,7 +2549,12 @@ mod tests { assert_eq!(app.session_remote_node_id("remote-1"), Some("node-1")); assert!(matches!( replies.as_slice(), - [Command::LoadSession { session_id, .. }] if session_id == "remote-1" + [ + Command::LoadSession { session_id: load_id, .. }, + Command::SubscribeSession { session_id: subscribe_id, agent_id }, + ] if load_id == "remote-1" + && subscribe_id == "remote-1" + && agent_id.as_deref() == Some("agent-1") )); } @@ -3564,6 +3570,7 @@ mod tests { fn native_undo_result_success_updates_state_and_reloads_session() { let mut app = App::new(); app.session_id = Some("session-1".into()); + app.agent_id = Some("agent-1".into()); app.activity = ActivityState::SessionOp(SessionOp::Undo); app.undoable_turns.push(UndoableTurn { turn_id: "u1".into(), @@ -3589,7 +3596,13 @@ mod tests { ); assert!(matches!( replies.as_slice(), - [Command::LoadSession { session_id, cwd }] if session_id == "session-1" && cwd.is_none() + [ + Command::LoadSession { session_id: load_id, cwd }, + Command::SubscribeSession { session_id: subscribe_id, agent_id }, + ] if load_id == "session-1" + && subscribe_id == "session-1" + && cwd.is_none() + && agent_id.as_deref() == Some("agent-1") )); } @@ -3685,6 +3698,7 @@ mod tests { fn native_redo_result_success_rebuilds_state_and_reloads_session() { let mut app = App::new(); app.session_id = Some("session-1".into()); + app.agent_id = Some("agent-1".into()); app.activity = ActivityState::SessionOp(SessionOp::Redo); let replies = app.handle_acp_event(AcpAppEvent::RedoResult(RedoResult::Applied { @@ -3699,7 +3713,13 @@ mod tests { assert!(app.can_redo()); assert!(matches!( replies.as_slice(), - [Command::LoadSession { session_id, cwd }] if session_id == "session-1" && cwd.is_none() + [ + Command::LoadSession { session_id: load_id, cwd }, + Command::SubscribeSession { session_id: subscribe_id, agent_id }, + ] if load_id == "session-1" + && subscribe_id == "session-1" + && cwd.is_none() + && agent_id.as_deref() == Some("agent-1") )); } @@ -3741,6 +3761,7 @@ mod tests { assert_eq!(app.pending_fork_message_id, None); assert_eq!(app.popup, Popup::None); assert_eq!(app.status, "forked - loading session"); + assert_eq!(replies.len(), 2); assert!(matches!( replies.as_slice(), [ diff --git a/src/command.rs b/src/command.rs index c912b44..a888dbd 100644 --- a/src/command.rs +++ b/src/command.rs @@ -14,17 +14,36 @@ impl SessionListRequest { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptBlock { + Text { text: String }, + ResourceLink { name: String, uri: String }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { + Init, ListSessions { request: SessionListRequest, cursor: Option, }, + ListRemoteNodes, ListRemoteSessions { node_id: String, offset: u32, limit: u32, }, + CreateRemoteSession { + node_id: String, + cwd: Option, + }, + AttachRemoteSession { + node_id: String, + session_id: String, + }, + DismissRemoteSession { + session_id: String, + }, CreateMeshInvite { mesh_name: Option, ttl: Option, @@ -38,6 +57,7 @@ pub enum Command { SetReasoningEffort { reasoning_effort: String, }, + ListProfiles, ListProfileAgents { profile_id: String, }, @@ -47,19 +67,68 @@ pub enum Command { model_id: Option, node_id: Option, }, + NewSession { + cwd: Option, + profile_id: Option, + }, LoadSession { session_id: String, cwd: Option, }, + Prompt { + prompt: Vec, + local_id: String, + }, + CancelSession, + ListAllModels { + refresh: bool, + }, + SetSessionModel { + session_id: String, + model_id: String, + node_id: Option, + }, SubscribeSession { session_id: String, agent_id: Option, }, + DeleteSession { + session_id: String, + }, + ForkSession { + message_id: String, + }, + Undo { + message_id: String, + }, + Redo, GetFileIndex, SetAgentMode { mode: String, }, + ElicitationResponse { + elicitation_id: String, + action: String, + content: Option, + }, ListAuthProviders, + StartOAuthLogin { + provider: String, + }, + CompleteOAuthLogin { + flow_id: String, + response: String, + }, + DisconnectOAuth { + provider: String, + }, + SetApiToken { + provider: String, + api_key: String, + }, + ClearApiToken { + provider: String, + }, } impl Command { @@ -99,4 +168,49 @@ impl Command { limit, } } + + pub fn load_session_commands( + session_id: String, + cwd: Option, + agent_id: Option, + ) -> [Self; 2] { + [ + Self::LoadSession { + session_id: session_id.clone(), + cwd, + }, + Self::SubscribeSession { + session_id, + agent_id, + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::Command; + + #[test] + fn load_session_commands_preserve_order_and_fields() { + let commands = Command::load_session_commands( + "session-1".into(), + Some("/repo".into()), + Some("agent-1".into()), + ); + + assert_eq!( + commands, + [ + Command::LoadSession { + session_id: "session-1".into(), + cwd: Some("/repo".into()), + }, + Command::SubscribeSession { + session_id: "session-1".into(), + agent_id: Some("agent-1".into()), + }, + ] + ); + } } diff --git a/src/handlers.rs b/src/handlers.rs index f3bdef1..ff3f9b4 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -10,8 +10,8 @@ use crate::domain::model::ModelEntry; fn popup_page_step(visible_rows: usize) -> usize { visible_rows.saturating_sub(1).max(1) } +use crate::command::{Command, PromptBlock}; use crate::config; -use crate::protocol::{ClientMsg, PromptBlock}; use crate::theme; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,6 +33,18 @@ pub(crate) fn can_send_server_commands(app: &mut App) -> bool { } } +fn send_load_session_commands( + cmd_tx: &mpsc::UnboundedSender, + session_id: String, + cwd: Option, + agent_id: Option, +) -> anyhow::Result<()> { + for command in Command::load_session_commands(session_id, cwd, agent_id) { + cmd_tx.send(command)?; + } + Ok(()) +} + /// Handle all keyboard input while an elicitation popup is active. /// /// Returns `Ok(())` in all cases; the caller should return immediately after @@ -40,7 +52,7 @@ pub(crate) fn can_send_server_commands(app: &mut App) -> bool { pub(crate) fn handle_elicitation_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { use crate::domain::elicitation::ElicitationFieldKind; @@ -104,7 +116,7 @@ pub(crate) fn handle_elicitation_key( let elicitation_id = state.elicitation_id.clone(); let content = state.build_accept_content(Some(&state.custom_input)); let display = selected_display(state, true); - cmd_tx.send(ClientMsg::ElicitationResponse { + cmd_tx.send(Command::ElicitationResponse { elicitation_id: elicitation_id.clone(), action: "accept".into(), content: Some(content), @@ -139,7 +151,7 @@ pub(crate) fn handle_elicitation_key( match key.code { KeyCode::Esc => { let elicitation_id = state.elicitation_id.clone(); - cmd_tx.send(ClientMsg::ElicitationResponse { + cmd_tx.send(Command::ElicitationResponse { elicitation_id: elicitation_id.clone(), action: "decline".into(), content: None, @@ -186,7 +198,7 @@ pub(crate) fn handle_elicitation_key( let elicitation_id = state.elicitation_id.clone(); let content = state.build_accept_content(None); let display = selected_display(state, false); - cmd_tx.send(ClientMsg::ElicitationResponse { + cmd_tx.send(Command::ElicitationResponse { elicitation_id: elicitation_id.clone(), action: "accept".into(), content: Some(content), @@ -222,10 +234,7 @@ pub(crate) fn handle_elicitation_key( Ok(()) } -fn open_model_popup( - app: &mut App, - cmd_tx: &mpsc::UnboundedSender, -) -> anyhow::Result<()> { +fn open_model_popup(app: &mut App, cmd_tx: &mpsc::UnboundedSender) -> anyhow::Result<()> { if app.screen != Screen::Chat { app.set_status( app::LogLevel::Warn, @@ -241,13 +250,13 @@ fn open_model_popup( app.model_filter.clear(); app.model_popup_agent_tab = 0; app.model_cursor = app.model_popup_open_cursor(); - cmd_tx.send(ClientMsg::ListAllModels { refresh: true })?; + cmd_tx.send(Command::ListAllModels { refresh: true })?; Ok(()) } fn open_session_popup( app: &mut App, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { if !can_send_server_commands(app) { return Ok(()); @@ -257,7 +266,7 @@ fn open_session_popup( app.session_cursor = 0; app.session_filter.clear(); if let Some(request) = app.begin_session_discovery() { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } Ok(()) } @@ -271,7 +280,7 @@ fn open_log_popup(app: &mut App) { fn execute_command_palette_action( app: &mut App, action: CommandPaletteAction, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match action { CommandPaletteAction::OpenMesh | CommandPaletteAction::AttachRemoteSession => { @@ -279,14 +288,14 @@ fn execute_command_palette_action( return Ok(()); } app.open_mesh_popup(); - cmd_tx.send(ClientMsg::ListRemoteNodes)?; + cmd_tx.send(Command::ListRemoteNodes)?; } CommandPaletteAction::CreateRemoteSession => { if !can_send_server_commands(app) { return Ok(()); } app.open_mesh_popup(); - cmd_tx.send(ClientMsg::ListRemoteNodes)?; + cmd_tx.send(Command::ListRemoteNodes)?; } CommandPaletteAction::CreateMeshInvite => { if !can_send_server_commands(app) { @@ -323,7 +332,7 @@ fn execute_command_palette_action( return Ok(()); } app.open_auth_popup(); - cmd_tx.send(ClientMsg::ListAuthProviders)?; + cmd_tx.send(Command::ListAuthProviders)?; } CommandPaletteAction::ForkTurnSelect => { app.open_fork_turn_popup(); @@ -333,7 +342,7 @@ fn execute_command_palette_action( return Ok(()); } app.open_profile_popup(); - cmd_tx.send(ClientMsg::ListProfiles)?; + cmd_tx.send(Command::ListProfiles)?; } } Ok(()) @@ -342,7 +351,7 @@ fn execute_command_palette_action( pub(crate) fn handle_mesh_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => app.popup = Popup::None, @@ -355,7 +364,7 @@ pub(crate) fn handle_mesh_popup_key( KeyCode::Up => match app.mesh_focus { crate::mesh::MeshFocus::Nodes => { if let Some(msg) = app.move_mesh_node_cursor(-1) { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; } } crate::mesh::MeshFocus::Sessions => app.move_remote_session_cursor(-1), @@ -363,26 +372,26 @@ pub(crate) fn handle_mesh_popup_key( KeyCode::Down => match app.mesh_focus { crate::mesh::MeshFocus::Nodes => { if let Some(msg) = app.move_mesh_node_cursor(1) { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; } } crate::mesh::MeshFocus::Sessions => app.move_remote_session_cursor(1), }, - KeyCode::Char('r') => cmd_tx.send(ClientMsg::ListRemoteNodes)?, + KeyCode::Char('r') => cmd_tx.send(Command::ListRemoteNodes)?, KeyCode::Enter => match app.mesh_focus { crate::mesh::MeshFocus::Nodes => { app.mesh_focus = crate::mesh::MeshFocus::Sessions; if let Some(node_id) = app.selected_mesh_node_id() { - cmd_tx.send(ClientMsg::ListRemoteSessions { + cmd_tx.send(Command::ListRemoteSessions { node_id: node_id.to_string(), - offset: Some(0), - limit: Some(50), + offset: 0, + limit: 50, })?; } } crate::mesh::MeshFocus::Sessions => { if let Some(session) = app.selected_remote_session() { - cmd_tx.send(ClientMsg::AttachRemoteSession { + cmd_tx.send(Command::AttachRemoteSession { node_id: session.node_id.clone(), session_id: session.id.clone(), })?; @@ -391,10 +400,9 @@ pub(crate) fn handle_mesh_popup_key( }, KeyCode::Char('n') => { if let Some(node_id) = app.selected_mesh_node_id() { - cmd_tx.send(ClientMsg::CreateRemoteSession { + cmd_tx.send(Command::CreateRemoteSession { node_id: node_id.to_string(), cwd: app.current_session_cwd(), - request_id: None, })?; } } @@ -406,7 +414,7 @@ pub(crate) fn handle_mesh_popup_key( pub(crate) fn handle_mesh_invite_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { if app.mesh_clipboard_fallback.is_some() { app.mesh_clipboard_fallback = None; @@ -446,7 +454,7 @@ pub(crate) fn handle_mesh_invite_popup_key( }, KeyCode::Enter => { if let Some(msg) = app.mesh_invite_form_command() { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; app.set_status(app::LogLevel::Info, "mesh", "creating invite..."); } } @@ -495,7 +503,7 @@ pub(crate) fn handle_mesh_invite_qr_popup_key(app: &mut App, key: KeyEvent) -> a pub(crate) fn handle_command_palette_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => app.popup = Popup::None, @@ -518,7 +526,7 @@ pub(crate) fn handle_command_palette_key( pub(crate) fn handle_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result { if key.code != KeyCode::Esc && app.pending_cancel_confirm_until.is_some() { app.clear_cancel_confirm(); @@ -578,7 +586,7 @@ pub(crate) fn handle_key( } match app.cycle_reasoning_effort() { Some(msg) => { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; app.set_status( app::LogLevel::Info, "model", @@ -723,7 +731,7 @@ pub(crate) fn save_config(app: &App) { pub(crate) fn handle_chord( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Char('m') => { @@ -759,7 +767,7 @@ pub(crate) fn handle_chord( return Ok(()); } app.open_auth_popup(); - cmd_tx.send(ClientMsg::ListAuthProviders)?; + cmd_tx.send(Command::ListAuthProviders)?; } KeyCode::Char('p') => { @@ -767,7 +775,7 @@ pub(crate) fn handle_chord( return Ok(()); } app.open_profile_popup(); - cmd_tx.send(ClientMsg::ListProfiles)?; + cmd_tx.send(Command::ListProfiles)?; } KeyCode::Char('j') => { if !matches!(app.screen, Screen::Chat | Screen::Delegate) { @@ -782,14 +790,12 @@ pub(crate) fn handle_chord( return Ok(()); } if let Some(parent_sid) = app.parent_session_id.clone() { - cmd_tx.send(ClientMsg::LoadSession { - session_id: parent_sid.clone(), - cwd: app.current_session_cwd(), - })?; - cmd_tx.send(ClientMsg::SubscribeSession { - session_id: parent_sid, - agent_id: app.agent_id.clone(), - })?; + send_load_session_commands( + cmd_tx, + parent_sid, + app.current_session_cwd(), + app.agent_id.clone(), + )?; } else { app.set_status(app::LogLevel::Info, "session", "no parent session"); } @@ -830,7 +836,7 @@ pub(crate) fn handle_chord( app.push_pending_undo(&turn); app.activity = ActivityState::SessionOp(SessionOp::Undo); app.set_status(app::LogLevel::Info, "session", "undoing..."); - cmd_tx.send(ClientMsg::Undo { + cmd_tx.send(Command::Undo { message_id: turn.message_id, })?; } else { @@ -852,7 +858,7 @@ pub(crate) fn handle_chord( } else if app.can_redo() { app.activity = ActivityState::SessionOp(SessionOp::Redo); app.set_status(app::LogLevel::Info, "session", "redoing..."); - cmd_tx.send(ClientMsg::Redo)?; + cmd_tx.send(Command::Redo)?; } else { app.set_status(app::LogLevel::Warn, "session", "nothing to redo"); } @@ -867,7 +873,7 @@ pub(crate) fn handle_chord( pub(crate) fn handle_sessions_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Char('q') | KeyCode::Esc => { @@ -884,7 +890,7 @@ pub(crate) fn handle_sessions_key( parent_path, } => { if let Some(request) = app.session_child_page_request(group_idx, &parent_path) { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } } SessionKeyAction::None => {} @@ -906,29 +912,27 @@ pub(crate) fn handle_sessions_key( agent_id, cwd, } => { - cmd_tx.send(ClientMsg::LoadSession { - session_id: session_id.clone(), - cwd, - })?; - cmd_tx.send(ClientMsg::SubscribeSession { + send_load_session_commands( + cmd_tx, session_id, - agent_id: agent_id.or_else(|| app.agent_id.clone()), - })?; + cwd, + agent_id.or_else(|| app.agent_id.clone()), + )?; } SessionKeyAction::AttachRemoteSession { node_id, session_id, } => { - cmd_tx.send(ClientMsg::AttachRemoteSession { + cmd_tx.send(Command::AttachRemoteSession { node_id, session_id, })?; } SessionKeyAction::DeleteSession { session_id } => { - cmd_tx.send(ClientMsg::DeleteSession { session_id })?; + cmd_tx.send(Command::DeleteSession { session_id })?; } SessionKeyAction::DismissRemoteSession { session_id } => { - cmd_tx.send(ClientMsg::DismissRemoteSession { session_id })?; + cmd_tx.send(Command::DismissRemoteSession { session_id })?; } SessionKeyAction::NewSession => { app.open_new_session_popup(); @@ -943,7 +947,7 @@ pub(crate) fn handle_sessions_key( app.session_child_page_request(group_idx, &parent_path) }; if let Some(request) = request { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } } SessionKeyAction::None => {} @@ -954,7 +958,7 @@ pub(crate) fn handle_sessions_key( pub(crate) fn handle_session_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { // Tab / BackTab: switch between sessions and delegates tabs if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) { @@ -983,7 +987,7 @@ pub(crate) fn handle_session_popup_key( parent_path, } => { if let Some(request) = app.session_child_page_request(group_idx, &parent_path) { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } } SessionKeyAction::None => {} @@ -1005,29 +1009,27 @@ pub(crate) fn handle_session_popup_key( agent_id, cwd, } => { - cmd_tx.send(ClientMsg::LoadSession { - session_id: session_id.clone(), - cwd, - })?; - cmd_tx.send(ClientMsg::SubscribeSession { + send_load_session_commands( + cmd_tx, session_id, - agent_id: agent_id.or_else(|| app.agent_id.clone()), - })?; + cwd, + agent_id.or_else(|| app.agent_id.clone()), + )?; } SessionKeyAction::AttachRemoteSession { node_id, session_id, } => { - cmd_tx.send(ClientMsg::AttachRemoteSession { + cmd_tx.send(Command::AttachRemoteSession { node_id, session_id, })?; } SessionKeyAction::DeleteSession { session_id } => { - cmd_tx.send(ClientMsg::DeleteSession { session_id })?; + cmd_tx.send(Command::DeleteSession { session_id })?; } SessionKeyAction::DismissRemoteSession { session_id } => { - cmd_tx.send(ClientMsg::DismissRemoteSession { session_id })?; + cmd_tx.send(Command::DismissRemoteSession { session_id })?; } SessionKeyAction::LoadMoreSessions { group_idx, @@ -1039,7 +1041,7 @@ pub(crate) fn handle_session_popup_key( app.session_child_page_request(group_idx, &parent_path) }; if let Some(request) = request { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } } SessionKeyAction::NewSession | SessionKeyAction::None => {} @@ -1228,7 +1230,7 @@ pub(crate) fn apply_session_fork_toggle_key(app: &mut App, popup_items: bool) -> fn handle_delegate_view_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result { match key.code { KeyCode::Up => { @@ -1253,14 +1255,12 @@ fn handle_delegate_view_key( KeyCode::Esc => { // Go back to parent session. if let Some(parent_sid) = app.parent_session_id.clone() { - cmd_tx.send(ClientMsg::LoadSession { - session_id: parent_sid.clone(), - cwd: app.current_session_cwd(), - })?; - cmd_tx.send(ClientMsg::SubscribeSession { - session_id: parent_sid, - agent_id: app.agent_id.clone(), - })?; + send_load_session_commands( + cmd_tx, + parent_sid, + app.current_session_cwd(), + app.agent_id.clone(), + )?; } } _ => {} @@ -1273,7 +1273,7 @@ fn handle_delegate_view_key( pub(crate) fn handle_delegate_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match apply_delegate_popup_key( app, @@ -1288,14 +1288,12 @@ pub(crate) fn handle_delegate_popup_key( agent_id, cwd, } => { - cmd_tx.send(ClientMsg::LoadSession { - session_id: session_id.clone(), - cwd, - })?; - cmd_tx.send(ClientMsg::SubscribeSession { + send_load_session_commands( + cmd_tx, session_id, - agent_id: agent_id.or_else(|| app.agent_id.clone()), - })?; + cwd, + agent_id.or_else(|| app.agent_id.clone()), + )?; } SessionKeyAction::NewSession | SessionKeyAction::AttachRemoteSession { .. } @@ -1384,7 +1382,7 @@ pub(crate) fn apply_delegate_popup_key( fn begin_fork_session( app: &mut App, message_id: String, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { if app.pending_fork_message_id.is_some() { app.set_status(LogLevel::Warn, "fork", "fork already pending"); @@ -1405,14 +1403,14 @@ fn begin_fork_session( app.pending_fork_message_id = Some(message_id.clone()); app.set_status(LogLevel::Info, "fork", "forking session..."); - cmd_tx.send(ClientMsg::ForkSession { message_id })?; + cmd_tx.send(Command::ForkSession { message_id })?; Ok(()) } pub(crate) fn handle_fork_turn_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => app.popup = Popup::None, @@ -1479,7 +1477,7 @@ pub(crate) fn handle_log_popup_key(app: &mut App, key: KeyEvent) -> anyhow::Resu pub(crate) fn handle_profile_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => { @@ -1505,7 +1503,7 @@ pub(crate) fn handle_profile_popup_key( app.agents.clear(); app.agents_profile_id = None; app.model_popup_agent_tab = 0; - cmd_tx.send(ClientMsg::ListProfileAgents { + cmd_tx.send(Command::ListProfileAgents { profile_id: profile_id.clone(), })?; } @@ -1528,7 +1526,7 @@ pub(crate) fn handle_profile_popup_key( pub(crate) fn handle_new_session_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => { @@ -1578,9 +1576,8 @@ pub(crate) fn handle_new_session_popup_key( } let cwd = app.normalize_new_session_path(&app.new_session_path); app.popup = Popup::None; - cmd_tx.send(ClientMsg::NewSession { + cmd_tx.send(Command::NewSession { cwd, - request_id: None, profile_id: app.active_profile_id.clone(), })?; } @@ -1660,7 +1657,7 @@ pub(crate) fn handle_theme_popup_key(app: &mut App, key: KeyEvent) -> anyhow::Re pub(crate) fn handle_chat_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result { if app.input_line_width == 0 { app.input_line_width = 1; @@ -1677,7 +1674,7 @@ pub(crate) fn handle_chat_key( if app.cancel_confirm_active() { app.clear_cancel_confirm(); app.set_status(app::LogLevel::Warn, "activity", "stopping..."); - cmd_tx.send(ClientMsg::CancelSession)?; + cmd_tx.send(Command::CancelSession)?; } else { app.arm_cancel_confirm(); } @@ -1701,7 +1698,7 @@ pub(crate) fn handle_chat_key( // 3. Accept mention completion. if app.mention_state.is_some() && app.accept_selected_mention() { if let Some(msg) = app.request_file_index_if_needed() { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; } return Ok(AppAction::None); } @@ -1724,7 +1721,7 @@ pub(crate) fn handle_chat_key( }); } let local_id = app.push_pending_prompt(text); - if let Err(error) = cmd_tx.send(ClientMsg::Prompt { + if let Err(error) = cmd_tx.send(Command::Prompt { prompt, local_id: local_id.clone(), }) { @@ -1749,13 +1746,13 @@ pub(crate) fn handle_chat_key( && app.accept_selected_mention() && let Some(msg) = app.request_file_index_if_needed() { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; } } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) && !input_blocked => { app.input_insert(c); if let Some(msg) = app.request_file_index_if_needed() { - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; } } KeyCode::Up => { @@ -1824,10 +1821,10 @@ pub(crate) fn handle_chat_key( /// for the target mode, and persists config/cache. fn switch_mode( app: &mut App, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, target: &str, ) -> anyhow::Result<()> { - cmd_tx.send(ClientMsg::SetAgentMode { + cmd_tx.send(Command::SetAgentMode { mode: target.to_string(), })?; @@ -1865,7 +1862,7 @@ enum SlashResult { /// (this allows `/undo` to optionally restore the previous turn text). fn try_execute_slash_command( app: &mut App, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result { // Extract the command name (first word after '/') and optional argument. let after_slash = app.input.trim_start_matches('/'); @@ -1974,7 +1971,7 @@ fn try_execute_slash_command( return Ok(SlashResult::Handled); } let msg = app.set_reasoning_effort(Some(&level)).unwrap(); - cmd_tx.send(msg.into())?; + cmd_tx.send(msg)?; app.set_status( app::LogLevel::Info, "model", @@ -1996,14 +1993,14 @@ fn try_execute_slash_command( } if arg.is_empty() { app.open_profile_popup(); - cmd_tx.send(ClientMsg::ListProfiles)?; + cmd_tx.send(Command::ListProfiles)?; } else if let Some(profile_id) = app.find_profile_id(&arg) { app.active_profile_id = Some(profile_id.clone()); if app.current_session_profile_id().is_none() { app.agents.clear(); app.agents_profile_id = None; app.model_popup_agent_tab = 0; - cmd_tx.send(ClientMsg::ListProfileAgents { + cmd_tx.send(Command::ListProfileAgents { profile_id: profile_id.clone(), })?; } @@ -2031,7 +2028,7 @@ fn try_execute_slash_command( app.session_cursor = 0; app.session_filter.clear(); if let Some(request) = app.begin_session_discovery() { - cmd_tx.send(request.into())?; + cmd_tx.send(request)?; } } "delegates" => { @@ -2073,7 +2070,7 @@ fn try_execute_slash_command( return Ok(SlashResult::Handled); } app.open_auth_popup(); - cmd_tx.send(ClientMsg::ListAuthProviders)?; + cmd_tx.send(Command::ListAuthProviders)?; } "fork" => { app.take_input(); @@ -2132,7 +2129,7 @@ fn try_execute_slash_command( app.push_pending_undo(&turn); app.activity = ActivityState::SessionOp(SessionOp::Undo); app.set_status(app::LogLevel::Info, "session", "undoing..."); - cmd_tx.send(ClientMsg::Undo { + cmd_tx.send(Command::Undo { message_id: turn.message_id, })?; } else { @@ -2155,7 +2152,7 @@ fn try_execute_slash_command( } else if app.can_redo() { app.activity = ActivityState::SessionOp(SessionOp::Redo); app.set_status(app::LogLevel::Info, "session", "redoing..."); - cmd_tx.send(ClientMsg::Redo)?; + cmd_tx.send(Command::Redo)?; } else { app.set_status(app::LogLevel::Warn, "session", "nothing to redo"); } @@ -2169,7 +2166,7 @@ fn try_execute_slash_command( if app.has_cancellable_activity() { app.clear_cancel_confirm(); app.set_status(app::LogLevel::Warn, "activity", "stopping..."); - cmd_tx.send(ClientMsg::CancelSession)?; + cmd_tx.send(Command::CancelSession)?; } else { app.set_status(app::LogLevel::Warn, "activity", "nothing to cancel"); } @@ -2189,7 +2186,7 @@ fn try_execute_slash_command( pub(crate) fn handle_auth_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { use crate::app::AuthPanel; @@ -2235,7 +2232,7 @@ pub(crate) fn handle_auth_popup_key( { app.auth_selected = Some(real_idx); let provider_id = provider.provider.clone(); - cmd_tx.send(ClientMsg::StartOAuthLogin { + cmd_tx.send(Command::StartOAuthLogin { provider: provider_id, })?; } else { @@ -2250,12 +2247,12 @@ pub(crate) fn handle_auth_popup_key( let provider = &app.auth_providers[idx]; if provider.oauth_status == Some(OAuthStatus::Connected) { let provider_id = provider.provider.clone(); - cmd_tx.send(ClientMsg::DisconnectOAuth { + cmd_tx.send(Command::DisconnectOAuth { provider: provider_id, })?; } else if provider.has_stored_api_key { let provider_id = provider.provider.clone(); - cmd_tx.send(ClientMsg::ClearApiToken { + cmd_tx.send(Command::ClearApiToken { provider: provider_id, })?; } @@ -2284,7 +2281,7 @@ pub(crate) fn handle_auth_popup_key( app.auth_ui_notice = None; app.auth_selected = Some(real_idx); let provider_id = provider.provider.clone(); - cmd_tx.send(ClientMsg::StartOAuthLogin { + cmd_tx.send(Command::StartOAuthLogin { provider: provider_id, })?; } @@ -2311,7 +2308,7 @@ pub(crate) fn handle_auth_popup_key( let trimmed = app.auth_api_key_input.trim().to_string(); if !trimmed.is_empty() { let provider = app.auth_providers[idx].provider.clone(); - cmd_tx.send(ClientMsg::SetApiToken { + cmd_tx.send(Command::SetApiToken { provider, api_key: trimmed, })?; @@ -2325,7 +2322,7 @@ pub(crate) fn handle_auth_popup_key( // Clear stored key if let Some(idx) = app.auth_selected { let provider = app.auth_providers[idx].provider.clone(); - cmd_tx.send(ClientMsg::ClearApiToken { provider })?; + cmd_tx.send(Command::ClearApiToken { provider })?; } } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -2382,7 +2379,7 @@ pub(crate) fn handle_auth_popup_key( app.auth_oauth_response.trim().to_string() }; if is_device_poll || !response.is_empty() { - cmd_tx.send(ClientMsg::CompleteOAuthLogin { flow_id, response })?; + cmd_tx.send(Command::CompleteOAuthLogin { flow_id, response })?; } } } @@ -2470,7 +2467,7 @@ fn try_copy_to_clipboard(app: &mut App, provider: &str, text: &str) { pub(crate) fn handle_model_popup_key( app: &mut App, key: KeyEvent, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match key.code { KeyCode::Esc => { @@ -2525,7 +2522,7 @@ pub(crate) fn handle_model_popup_key( if app.model_popup_is_session_tab(app.model_popup_agent_tab) { if !app.current_session_is_remote() { if let Some(sid) = app.session_id.clone() { - cmd_tx.send(ClientMsg::SetSessionModel { + cmd_tx.send(Command::SetSessionModel { session_id: sid, model_id: model.id.clone(), node_id: model.node_id.clone(), @@ -2534,7 +2531,7 @@ pub(crate) fn handle_model_popup_key( app.apply_model_selection_from_entry(&model); if app.reasoning_effort.is_some() { app.reasoning_effort = None; - cmd_tx.send(ClientMsg::SetReasoningEffort { + cmd_tx.send(Command::SetReasoningEffort { reasoning_effort: "auto".into(), })?; } @@ -2554,7 +2551,7 @@ pub(crate) fn handle_model_popup_key( if app.parent_session_id.is_none() && let Some(session_id) = app.session_id.clone() { - cmd_tx.send(ClientMsg::SetDelegateModel { + cmd_tx.send(Command::SetDelegateModel { session_id, agent_id, model_id: Some(model.id.clone()), @@ -2580,7 +2577,7 @@ pub(crate) fn handle_model_popup_key( if app.parent_session_id.is_none() && let Some(session_id) = app.session_id.clone() { - cmd_tx.send(ClientMsg::SetDelegateModel { + cmd_tx.send(Command::SetDelegateModel { session_id, agent_id, model_id: None, @@ -2610,7 +2607,7 @@ pub(crate) fn handle_model_popup_key( // ── Pure key logic for the sessions screen ──────────────────────────────────── // -// `apply_sessions_key` returns the `ClientMsg`(s) that should be sent to the +// `apply_sessions_key` returns the `Command`(s) that should be sent to the // server (if any). Keeping the mutation separate from the channel send makes // it fully unit-testable without a real channel. @@ -2909,7 +2906,7 @@ mod model_popup_tests { handle_command_palette_key(&mut app, key(KeyCode::Enter), &tx).unwrap(); assert!(matches!(app.popup, Popup::Mesh)); - assert!(matches!(rx.try_recv(), Ok(ClientMsg::ListRemoteNodes))); + assert!(matches!(rx.try_recv(), Ok(Command::ListRemoteNodes))); } #[test] @@ -2937,7 +2934,7 @@ mod model_popup_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::AttachRemoteSession { node_id, session_id }) + Ok(Command::AttachRemoteSession { node_id, session_id }) if node_id == "node-1" && session_id == "remote-1" )); } @@ -3028,7 +3025,7 @@ mod model_popup_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::CreateMeshInvite { mesh_name, ttl, max_uses }) + Ok(Command::CreateMeshInvite { mesh_name, ttl, max_uses }) if mesh_name.as_deref() == Some("Team Mesh") && ttl.as_deref() == Some("1d3h5m") && max_uses == Some(1) @@ -3063,7 +3060,7 @@ mod model_popup_tests { handle_command_palette_key(&mut app, key(KeyCode::Enter), &tx).unwrap(); assert!(matches!(app.popup, Popup::ProfileSelect)); - assert!(matches!(rx.try_recv(), Ok(ClientMsg::ListProfiles))); + assert!(matches!(rx.try_recv(), Ok(Command::ListProfiles))); } #[test] @@ -3079,7 +3076,7 @@ mod model_popup_tests { assert!(matches!(action, AppAction::None)); assert!(matches!(app.popup, Popup::ProfileSelect)); - assert!(matches!(rx.try_recv(), Ok(ClientMsg::ListProfiles))); + assert!(matches!(rx.try_recv(), Ok(Command::ListProfiles))); } #[test] @@ -3098,7 +3095,7 @@ mod model_popup_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::ListProfileAgents { profile_id }) if profile_id == "fast" + Ok(Command::ListProfileAgents { profile_id }) if profile_id == "fast" )); assert_eq!(app.active_profile_id.as_deref(), Some("fast")); } @@ -3118,7 +3115,7 @@ mod model_popup_tests { assert!(matches!(app.popup, Popup::None)); assert!(matches!( rx.try_recv(), - Ok(ClientMsg::ListProfileAgents { profile_id }) if profile_id == "deep" + Ok(Command::ListProfileAgents { profile_id }) if profile_id == "deep" )); assert_eq!(app.active_profile_id.as_deref(), Some("deep")); } @@ -3137,7 +3134,7 @@ mod model_popup_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::NewSession { profile_id: Some(profile_id), .. }) + Ok(Command::NewSession { profile_id: Some(profile_id), .. }) if profile_id == "coder-delegate" )); } @@ -3175,7 +3172,7 @@ mod model_popup_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::SetDelegateModel { + Ok(Command::SetDelegateModel { ref session_id, ref agent_id, ref model_id, @@ -3218,7 +3215,7 @@ mod model_popup_tests { ); assert!(matches!( rx.try_recv(), - Ok(ClientMsg::SetDelegateModel { + Ok(Command::SetDelegateModel { ref session_id, ref agent_id, model_id: None, @@ -3286,10 +3283,10 @@ mod model_popup_tests { handle_model_popup_key(&mut app, key(KeyCode::Enter), &tx).unwrap(); let msg1 = rx.try_recv().expect("expected SetSessionModel"); - assert!(matches!(msg1, ClientMsg::SetSessionModel { .. })); + assert!(matches!(msg1, Command::SetSessionModel { .. })); let msg2 = rx.try_recv().expect("expected SetReasoningEffort auto"); assert!( - matches!(msg2, ClientMsg::SetReasoningEffort { reasoning_effort } if reasoning_effort == "auto") + matches!(msg2, Command::SetReasoningEffort { reasoning_effort } if reasoning_effort == "auto") ); assert!(rx.try_recv().is_err()); @@ -3336,7 +3333,7 @@ mod model_popup_tests { handle_model_popup_key(&mut app, key(KeyCode::Enter), &tx).unwrap(); match rx.try_recv().expect("SetSessionModel") { - ClientMsg::SetSessionModel { + Command::SetSessionModel { node_id, model_id, .. } => { assert_eq!(node_id.as_deref(), Some("node-a")); @@ -3434,7 +3431,7 @@ mod model_popup_tests { assert_eq!(app.mode_before_review.as_deref(), Some("plan")); assert!(matches!( rx.try_recv().expect("expected SetAgentMode(review)"), - ClientMsg::SetAgentMode { mode } if mode == "review" + Command::SetAgentMode { mode } if mode == "review" )); assert!(rx.try_recv().is_err()); @@ -3443,7 +3440,7 @@ mod model_popup_tests { assert_eq!(app.mode_before_review, None); assert!(matches!( rx.try_recv().expect("expected SetAgentMode(plan)"), - ClientMsg::SetAgentMode { mode } if mode == "plan" + Command::SetAgentMode { mode } if mode == "plan" )); assert!(rx.try_recv().is_err()); } @@ -3476,11 +3473,11 @@ mod model_popup_tests { assert!(matches!( rx.try_recv().expect("expected LoadSession"), - ClientMsg::LoadSession { session_id, .. } if session_id == "child-1" + Command::LoadSession { session_id, .. } if session_id == "child-1" )); assert!(matches!( rx.try_recv().expect("expected SubscribeSession"), - ClientMsg::SubscribeSession { session_id, agent_id } + Command::SubscribeSession { session_id, agent_id } if session_id == "child-1" && agent_id.as_deref() == Some("coder") )); assert!(rx.try_recv().is_err()); diff --git a/src/mesh.rs b/src/mesh.rs index fc008d2..a9dcfec 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -197,10 +197,12 @@ impl App { if attached { self.popup = Popup::None; self.set_status(LogLevel::Info, "mesh", "remote session attached"); - vec![Command::LoadSession { - session_id: session_id.to_string(), - cwd: self.current_session_cwd(), - }] + Command::load_session_commands( + session_id.to_string(), + self.current_session_cwd(), + self.agent_id.clone(), + ) + .into() } else { self.set_status(LogLevel::Info, "mesh", "remote session created"); vec![Command::ListRemoteSessions { diff --git a/src/protocol.rs b/src/protocol.rs index 81f6d68..4e7c1f1 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,10 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::command::Command; +use crate::command::SessionListRequest; use crate::domain::auth::{AuthMethod, AuthProviderEntry, OAuthFlowKind}; -pub(crate) use crate::command::SessionListRequest; - // --- Client → Server messages --- #[derive(Debug, Serialize)] @@ -160,101 +158,48 @@ pub enum ClientMsg { }, } -impl From for ClientMsg { - fn from(command: Command) -> Self { - match command { - Command::ListSessions { request, cursor } => { - let (mode, limit, cwd, include_remote) = match &request { - SessionListRequest::Discovery => (None, None, None, Some(true)), - SessionListRequest::WorkspaceFirstPage { cwd } - | SessionListRequest::WorkspaceContinuation { cwd } => { - (Some("group".to_string()), Some(10), Some(cwd.clone()), None) - } - }; - Self::ListSessions { - request, - mode, - cursor, - limit, - cwd, - query: None, - include_remote, - session_scope: SessionScope::Root, - } - } - Command::ListRemoteSessions { - node_id, - offset, - limit, - } => Self::ListRemoteSessions { - node_id, - offset: Some(offset), - limit: Some(limit), - }, - Command::CreateMeshInvite { - mesh_name, - ttl, - max_uses, - } => Self::CreateMeshInvite { - mesh_name, - ttl, - max_uses, - }, - Command::ListSessionChildren { - parent_session_id, - cursor, - limit, - } => Self::ListSessionChildren { - parent_session_id, - cursor, - limit: Some(limit), - session_scope: SessionScope::Forks, - }, - Command::SetReasoningEffort { reasoning_effort } => { - Self::SetReasoningEffort { reasoning_effort } - } - Command::ListProfileAgents { profile_id } => Self::ListProfileAgents { profile_id }, - Command::SetDelegateModel { - session_id, - agent_id, - model_id, - node_id, - } => Self::SetDelegateModel { - session_id, - agent_id, - model_id, - node_id, - }, - Command::LoadSession { session_id, cwd } => Self::LoadSession { session_id, cwd }, - Command::SubscribeSession { - session_id, - agent_id, - } => Self::SubscribeSession { - session_id, - agent_id, - }, - Command::GetFileIndex => Self::GetFileIndex, - Command::SetAgentMode { mode } => Self::SetAgentMode { mode }, - Command::ListAuthProviders => Self::ListAuthProviders, - } - } -} - impl ClientMsg { pub fn list_sessions_browse() -> Self { - Command::list_sessions_browse().into() + Self::list_sessions_discovery(None) } pub fn list_sessions_discovery(cursor: Option) -> Self { - Command::list_sessions_discovery(cursor).into() + Self::ListSessions { + request: SessionListRequest::Discovery, + mode: None, + cursor, + limit: None, + cwd: None, + query: None, + include_remote: Some(true), + session_scope: SessionScope::Root, + } } pub fn list_sessions_workspace(cwd: String) -> Self { - Command::list_sessions_workspace(cwd).into() + Self::ListSessions { + request: SessionListRequest::WorkspaceFirstPage { cwd: cwd.clone() }, + mode: Some("group".to_string()), + cursor: None, + limit: Some(10), + cwd: Some(cwd), + query: None, + include_remote: None, + session_scope: SessionScope::Root, + } } pub fn list_sessions_group(cwd: String, cursor: String) -> Self { - Command::list_sessions_group(cwd, cursor).into() + Self::ListSessions { + request: SessionListRequest::WorkspaceContinuation { cwd: cwd.clone() }, + mode: Some("group".to_string()), + cursor: Some(cursor), + limit: Some(10), + cwd: Some(cwd), + query: None, + include_remote: None, + session_scope: SessionScope::Root, + } } pub fn list_session_children( @@ -262,193 +207,12 @@ impl ClientMsg { cursor: Option, limit: u32, ) -> Self { - Command::list_session_children(parent_session_id, cursor, limit).into() - } -} - -#[cfg(test)] -mod command_conversion_tests { - use super::{ClientMsg, SessionListRequest, SessionScope}; - use crate::command::Command; - - #[test] - fn list_session_commands_preserve_semantics_and_bound_wire_fields() { - let discovery = ClientMsg::from(Command::list_sessions_discovery(Some("root-2".into()))); - assert!(matches!( - discovery, - ClientMsg::ListSessions { - request: SessionListRequest::Discovery, - mode: None, - cursor: Some(cursor), - limit: None, - cwd: None, - query: None, - include_remote: Some(true), - session_scope: SessionScope::Root, - } if cursor == "root-2" - )); - - let first = ClientMsg::from(Command::list_sessions_workspace("/repo".into())); - assert!(matches!( - first, - ClientMsg::ListSessions { - request: SessionListRequest::WorkspaceFirstPage { cwd: request_cwd }, - mode: Some(mode), - cursor: None, - limit: Some(10), - cwd: Some(wire_cwd), - query: None, - include_remote: None, - session_scope: SessionScope::Root, - } if request_cwd == "/repo" && mode == "group" && wire_cwd == "/repo" - )); - - let continuation = ClientMsg::from(Command::list_sessions_group( - "/repo".into(), - "workspace-2".into(), - )); - assert!(matches!( - continuation, - ClientMsg::ListSessions { - request: SessionListRequest::WorkspaceContinuation { cwd: request_cwd }, - mode: Some(mode), - cursor: Some(cursor), - limit: Some(10), - cwd: Some(wire_cwd), - query: None, - include_remote: None, - session_scope: SessionScope::Root, - } if request_cwd == "/repo" - && mode == "group" - && cursor == "workspace-2" - && wire_cwd == "/repo" - )); - - let children = ClientMsg::from(Command::list_session_children( - "parent-1".into(), - Some("child-2".into()), - 25, - )); - assert!(matches!( - children, - ClientMsg::ListSessionChildren { - parent_session_id, - cursor: Some(cursor), - limit: Some(25), - session_scope: SessionScope::Forks, - } if parent_session_id == "parent-1" && cursor == "child-2" - )); - } - - #[test] - fn mesh_and_model_commands_convert_exactly() { - let remote = ClientMsg::from(Command::ListRemoteSessions { - node_id: "node-1".into(), - offset: 5, - limit: 50, - }); - assert!(matches!( - remote, - ClientMsg::ListRemoteSessions { - node_id, - offset: Some(5), - limit: Some(50), - } if node_id == "node-1" - )); - - let invite = ClientMsg::from(Command::CreateMeshInvite { - mesh_name: Some("team".into()), - ttl: Some("24h".into()), - max_uses: Some(3), - }); - assert!(matches!( - invite, - ClientMsg::CreateMeshInvite { - mesh_name: Some(mesh_name), - ttl: Some(ttl), - max_uses: Some(3), - } if mesh_name == "team" && ttl == "24h" - )); - - let effort = ClientMsg::from(Command::SetReasoningEffort { - reasoning_effort: "high".into(), - }); - assert!(matches!( - effort, - ClientMsg::SetReasoningEffort { reasoning_effort } if reasoning_effort == "high" - )); - - let agents = ClientMsg::from(Command::ListProfileAgents { - profile_id: "code".into(), - }); - assert!(matches!( - agents, - ClientMsg::ListProfileAgents { profile_id } if profile_id == "code" - )); - - let delegate = ClientMsg::from(Command::SetDelegateModel { - session_id: "session-1".into(), - agent_id: "reviewer".into(), - model_id: Some("provider/model".into()), - node_id: Some("node-2".into()), - }); - assert!(matches!( - delegate, - ClientMsg::SetDelegateModel { - session_id, - agent_id, - model_id: Some(model_id), - node_id: Some(node_id), - } if session_id == "session-1" - && agent_id == "reviewer" - && model_id == "provider/model" - && node_id == "node-2" - )); - } - - #[test] - fn session_and_input_commands_convert_exactly() { - let load = ClientMsg::from(Command::LoadSession { - session_id: "session-1".into(), - cwd: Some("/repo".into()), - }); - assert!(matches!( - load, - ClientMsg::LoadSession { - session_id, - cwd: Some(cwd), - } if session_id == "session-1" && cwd == "/repo" - )); - - let subscribe = ClientMsg::from(Command::SubscribeSession { - session_id: "session-1".into(), - agent_id: Some("agent-1".into()), - }); - assert!(matches!( - subscribe, - ClientMsg::SubscribeSession { - session_id, - agent_id: Some(agent_id), - } if session_id == "session-1" && agent_id == "agent-1" - )); - - assert!(matches!( - ClientMsg::from(Command::GetFileIndex), - ClientMsg::GetFileIndex - )); - - let mode = ClientMsg::from(Command::SetAgentMode { - mode: "plan".into(), - }); - assert!(matches!( - mode, - ClientMsg::SetAgentMode { mode } if mode == "plan" - )); - - assert!(matches!( - ClientMsg::from(Command::ListAuthProviders), - ClientMsg::ListAuthProviders - )); + Self::ListSessionChildren { + parent_session_id, + cursor, + limit: Some(limit), + session_scope: SessionScope::Forks, + } } } @@ -787,6 +551,39 @@ mod client_msg_tests { ); } + #[test] + fn prompt_serializes_exact_shape() { + let value = serde_json::to_value(ClientMsg::Prompt { + prompt: vec![ + super::PromptBlock::Text { + text: "inspect this".into(), + }, + super::PromptBlock::ResourceLink { + name: "main.rs".into(), + uri: "file:///repo/src/main.rs".into(), + }, + ], + local_id: "local-1".into(), + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "type": "prompt", + "data": { + "prompt": [ + { "type": "text", "data": { "text": "inspect this" } }, + { + "type": "resource_link", + "data": { "name": "main.rs", "uri": "file:///repo/src/main.rs" } + } + ] + } + }) + ); + } + #[test] fn new_session_serializes_optional_profile_id() { let without_profile = serde_json::to_value(ClientMsg::NewSession { diff --git a/src/runtime/connection.rs b/src/runtime/connection.rs index 8f66a4d..2728cf1 100644 --- a/src/runtime/connection.rs +++ b/src/runtime/connection.rs @@ -5,7 +5,7 @@ use tokio::sync::mpsc; use crate::{ acp_client::{self, AcpEndpoint}, app::ConnectionEvent, - protocol::ClientMsg, + command::Command, server_manager::ServerEvent, }; @@ -19,7 +19,7 @@ fn reconnect_delay_ms(attempt: u32) -> u64 { pub(super) async fn connection_manager( endpoint: AcpEndpoint, srv_tx: mpsc::UnboundedSender, - mut cmd_rx: mpsc::UnboundedReceiver, + mut cmd_rx: mpsc::UnboundedReceiver, conn_tx: mpsc::UnboundedSender, sup_event_tx: mpsc::UnboundedSender, launch_cwd: Option, diff --git a/src/runtime/event_loop.rs b/src/runtime/event_loop.rs index 4599333..853a1e1 100644 --- a/src/runtime/event_loop.rs +++ b/src/runtime/event_loop.rs @@ -8,7 +8,6 @@ use crate::{ app::{self, App}, command::Command, handlers::{AppAction, handle_key, handle_mouse}, - protocol::ClientMsg, server_manager::{self, ServerEvent, ServerState}, ui, }; @@ -24,23 +23,8 @@ fn tick_from_elapsed(elapsed: Duration) -> u64 { (elapsed.as_millis() / 80) as u64 } -fn send_command( - cmd_tx: &mpsc::UnboundedSender, - command: Command, - agent_id: &Option, -) -> anyhow::Result<()> { - let load_session_id = match &command { - Command::LoadSession { session_id, .. } => Some(session_id.clone()), - _ => None, - }; - - cmd_tx.send(command.into())?; - if let Some(session_id) = load_session_id { - cmd_tx.send(ClientMsg::SubscribeSession { - session_id, - agent_id: agent_id.clone(), - })?; - } +fn send_command(cmd_tx: &mpsc::UnboundedSender, command: Command) -> anyhow::Result<()> { + cmd_tx.send(command)?; Ok(()) } @@ -50,7 +34,7 @@ pub(super) async fn run_loop( srv_rx: &mut mpsc::UnboundedReceiver, conn_rx: &mut mpsc::UnboundedReceiver, sup_rx: &mut mpsc::UnboundedReceiver, - cmd_tx: &mpsc::UnboundedSender, + cmd_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { let mut term_events = EventStream::new(); @@ -67,12 +51,12 @@ pub(super) async fn run_loop( let was_connected = app.conn == app::ConnState::Connected; app.handle_connection_event(state); if app.conn == app::ConnState::Connected { - cmd_tx.send(ClientMsg::Init)?; - cmd_tx.send(ClientMsg::list_sessions_browse())?; - cmd_tx.send(ClientMsg::ListAllModels { refresh: false })?; + cmd_tx.send(Command::Init)?; + cmd_tx.send(Command::list_sessions_browse())?; + cmd_tx.send(Command::ListAllModels { refresh: false })?; if let Some(session_id) = app.session_id.clone() { if let Some(node_id) = app.session_remote_node_id(&session_id) { - cmd_tx.send(ClientMsg::AttachRemoteSession { + cmd_tx.send(Command::AttachRemoteSession { node_id: node_id.to_string(), session_id, })?; @@ -83,14 +67,13 @@ pub(super) async fn run_loop( "remote session is missing node id; reconnect attach skipped", ); } else { - cmd_tx.send(ClientMsg::LoadSession { - session_id: session_id.clone(), - cwd: app.current_session_cwd(), - })?; - cmd_tx.send(ClientMsg::SubscribeSession { + for command in Command::load_session_commands( session_id, - agent_id: app.agent_id.clone(), - })?; + app.current_session_cwd(), + app.agent_id.clone(), + ) { + cmd_tx.send(command)?; + } } } } else if was_connected && app.conn == app::ConnState::Disconnected { @@ -105,7 +88,7 @@ pub(super) async fn run_loop( } Some(ServerChannelMsg::Acp(event)) = srv_rx.recv() => { for command in app.handle_acp_event(event) { - send_command(cmd_tx, command, &app.agent_id)?; + send_command(cmd_tx, command)?; } } Some(sup_event) = sup_rx.recv() => { @@ -200,10 +183,10 @@ mod tests { use tokio::sync::mpsc; use super::{send_command, tick_from_elapsed}; - use crate::{command::Command, protocol::ClientMsg}; + use crate::command::Command; #[test] - fn load_session_command_sends_load_and_subscribe() { + fn send_command_sends_each_command_once_without_implicit_subscribe() { let (tx, mut rx) = mpsc::unbounded_channel(); send_command( @@ -212,24 +195,16 @@ mod tests { session_id: "session-1".into(), cwd: Some("/repo".into()), }, - &Some("agent-1".into()), ) .unwrap(); assert!(matches!( rx.try_recv().unwrap(), - ClientMsg::LoadSession { + Command::LoadSession { session_id, cwd: Some(cwd), } if session_id == "session-1" && cwd == "/repo" )); - assert!(matches!( - rx.try_recv().unwrap(), - ClientMsg::SubscribeSession { - session_id, - agent_id: Some(agent_id), - } if session_id == "session-1" && agent_id == "agent-1" - )); assert!(rx.try_recv().is_err()); } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index d5769a8..898d54f 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -10,9 +10,9 @@ use crate::{ acp_client, acp_state::AcpAppEvent, app::{self, App, Screen}, + command::Command, config, domain::model::DelegateModelPreference, - protocol::ClientMsg, server_manager, theme, }; use clap::Parser; @@ -134,7 +134,7 @@ mod tests { // Accept response sent let msg = rx.try_recv().expect("message sent"); assert!(matches!(msg, - ClientMsg::ElicitationResponse { action, content: Some(ref c), .. } + Command::ElicitationResponse { action, content: Some(ref c), .. } if action == "accept" && c["choice"] == "b" )); @@ -174,7 +174,7 @@ mod tests { assert!(app.elicitation.is_none()); assert!(matches!(rx.try_recv().expect("message sent"), - ClientMsg::ElicitationResponse { action, content: Some(ref c), .. } + Command::ElicitationResponse { action, content: Some(ref c), .. } if action == "accept" && c["choice"] == "custom\nanswer" )); } @@ -199,7 +199,7 @@ mod tests { handle_elicitation_key(&mut app, key(KeyCode::Esc), &tx).unwrap(); assert!(app.elicitation.is_none()); assert!(matches!(rx.try_recv().expect("decline sent"), - ClientMsg::ElicitationResponse { action, .. } if action == "decline" + Command::ElicitationResponse { action, .. } if action == "decline" )); } @@ -227,7 +227,7 @@ mod tests { assert!(app.elicitation.is_none()); let msg = rx.try_recv().expect("message sent"); assert!(matches!(msg, - ClientMsg::ElicitationResponse { action, .. } if action == "decline" + Command::ElicitationResponse { action, .. } if action == "decline" )); assert!(app.messages.iter().any(|m| matches!(m, ChatEntry::Elicitation { outcome: Some(o), .. } if o == "declined" @@ -251,7 +251,7 @@ mod tests { assert!(app.elicitation.is_none()); let msg = rx.try_recv().expect("message sent"); assert!(matches!(msg, - ClientMsg::ElicitationResponse { action, content: Some(ref c), .. } + Command::ElicitationResponse { action, content: Some(ref c), .. } if action == "accept" && c["name"] == "Alice" )); assert!(app.messages.iter().any(|m| matches!(m, @@ -326,7 +326,7 @@ mod tests { assert!(app.elicitation.is_none()); let msg = rx.try_recv().expect("message sent"); assert!(matches!(msg, - ClientMsg::ElicitationResponse { action, content: Some(ref c), .. } + Command::ElicitationResponse { action, content: Some(ref c), .. } if action == "accept" && c["confirm"] == true )); assert!(app.messages.iter().any(|m| matches!(m, @@ -353,7 +353,7 @@ mod tests { assert!(app.elicitation.is_none()); let msg = rx.try_recv().expect("message sent"); assert!(matches!(msg, - ClientMsg::ElicitationResponse { action, content: Some(ref c), .. } + Command::ElicitationResponse { action, content: Some(ref c), .. } if action == "accept" && c["confirm"] == false )); assert!(app.messages.iter().any(|m| matches!(m, @@ -483,11 +483,11 @@ mod tests { mod external_editor_tests { use super::*; use crate::app::App; + use crate::command::PromptBlock; use crate::config::{AcpConfig, TestPersistenceGuard, TuiConfig}; use crate::domain::activity::{ActivityState, SessionOp}; use crate::domain::chat::ChatEntry; use crate::handlers::*; - use crate::protocol::PromptBlock; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serial_test::serial; @@ -501,7 +501,7 @@ mod external_editor_tests { #[test] fn chat_up_down_navigate_wrapped_input_without_scrolling_history() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "abcdef".into(); @@ -530,7 +530,7 @@ mod external_editor_tests { #[test] fn chat_pageup_pagedown_still_scroll_history() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.scroll_offset = 3; @@ -554,7 +554,7 @@ mod external_editor_tests { #[test] fn ctrl_x_e_returns_open_editor_action_in_chat() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "draft".into(); @@ -572,7 +572,7 @@ mod external_editor_tests { #[test] fn ctrl_x_e_outside_chat_stays_in_tui() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Sessions; assert_eq!( @@ -589,7 +589,7 @@ mod external_editor_tests { #[test] fn ctrl_x_m_outside_chat_does_not_open_model_popup() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Sessions; assert_eq!( @@ -637,7 +637,7 @@ mod external_editor_tests { #[test] fn chat_input_accepts_typing_and_submit_while_turn_active() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -660,7 +660,7 @@ mod external_editor_tests { assert!(matches!( rx.try_recv().expect("prompt sent"), - ClientMsg::Prompt { prompt, local_id } + Command::Prompt { prompt, local_id } if local_id.starts_with("local:pending:") && matches!(prompt.as_slice(), [PromptBlock::Text { text }] if text == "n") )); @@ -674,7 +674,7 @@ mod external_editor_tests { #[test] fn chat_submit_normalizes_prompt_before_sending_and_rendering() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -690,7 +690,7 @@ mod external_editor_tests { assert!(matches!( rx.try_recv().expect("prompt sent"), - ClientMsg::Prompt { prompt, .. } + Command::Prompt { prompt, .. } if matches!(prompt.as_slice(), [PromptBlock::Text { text }] if text == "first line\nsecond line") )); @@ -702,7 +702,7 @@ mod external_editor_tests { #[test] fn whitespace_only_chat_submit_does_not_send_or_render_prompt() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -725,7 +725,7 @@ mod external_editor_tests { #[test] fn left_arrow_with_slash_input_does_not_crash() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/model".into(); @@ -748,7 +748,7 @@ mod external_editor_tests { #[test] fn slash_esc_clears_slash_state() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/mo".into(); @@ -768,7 +768,7 @@ mod external_editor_tests { #[test] fn slash_enter_opens_help_popup() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/help".into(); @@ -788,7 +788,7 @@ mod external_editor_tests { #[test] fn slash_enter_with_partial_completion_executes_command() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/hel".into(); @@ -810,7 +810,7 @@ mod external_editor_tests { #[test] fn slash_tab_completes_command_name_without_executing() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/hel".into(); @@ -832,7 +832,7 @@ mod external_editor_tests { #[test] fn slash_down_up_navigates_selection() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/".into(); @@ -864,7 +864,7 @@ mod external_editor_tests { #[serial] fn slash_mode_no_arg_cycles_mode() { let _guard = TestPersistenceGuard::new("slash-mode-cycle"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -885,7 +885,7 @@ mod external_editor_tests { // SetAgentMode should have been sent assert!(matches!( rx.try_recv().expect("SetAgentMode sent"), - ClientMsg::SetAgentMode { mode } if mode == "plan" + Command::SetAgentMode { mode } if mode == "plan" )); } @@ -893,7 +893,7 @@ mod external_editor_tests { #[serial] fn slash_mode_plan_switches_to_plan() { let _guard = TestPersistenceGuard::new("slash-mode-plan"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -912,13 +912,13 @@ mod external_editor_tests { assert_eq!(app.agent_mode, "plan"); assert!(matches!( rx.try_recv().expect("SetAgentMode sent"), - ClientMsg::SetAgentMode { mode } if mode == "plan" + Command::SetAgentMode { mode } if mode == "plan" )); } #[test] fn slash_mode_same_is_idempotent() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -940,7 +940,7 @@ mod external_editor_tests { #[test] fn slash_mode_unknown_shows_error() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -962,7 +962,7 @@ mod external_editor_tests { #[serial] fn slash_thinking_high_sets_level() { let _guard = TestPersistenceGuard::new("slash-thinking-high"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -980,7 +980,7 @@ mod external_editor_tests { assert_eq!(app.reasoning_effort, Some("high".into())); assert!(matches!( rx.try_recv().expect("SetReasoningEffort sent"), - ClientMsg::SetReasoningEffort { reasoning_effort } if reasoning_effort == "high" + Command::SetReasoningEffort { reasoning_effort } if reasoning_effort == "high" )); } @@ -988,7 +988,7 @@ mod external_editor_tests { #[serial] fn slash_thinking_auto_clears_level() { let _guard = TestPersistenceGuard::new("slash-thinking-auto"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1007,7 +1007,7 @@ mod external_editor_tests { assert_eq!(app.reasoning_effort, None); assert!(matches!( rx.try_recv().expect("SetReasoningEffort sent"), - ClientMsg::SetReasoningEffort { reasoning_effort } if reasoning_effort == "auto" + Command::SetReasoningEffort { reasoning_effort } if reasoning_effort == "auto" )); } @@ -1015,7 +1015,7 @@ mod external_editor_tests { #[serial] fn slash_thinking_med_alias_sets_medium() { let _guard = TestPersistenceGuard::new("slash-thinking-med"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1033,13 +1033,13 @@ mod external_editor_tests { assert_eq!(app.reasoning_effort, Some("medium".into())); assert!(matches!( rx.try_recv().expect("SetReasoningEffort sent"), - ClientMsg::SetReasoningEffort { reasoning_effort } if reasoning_effort == "medium" + Command::SetReasoningEffort { reasoning_effort } if reasoning_effort == "medium" )); } #[test] fn slash_thinking_no_arg_shows_current() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.reasoning_effort = Some("high".into()); @@ -1058,7 +1058,7 @@ mod external_editor_tests { #[test] fn slash_thinking_unknown_shows_error() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.input = "/thinking xyz".into(); @@ -1076,7 +1076,7 @@ mod external_editor_tests { #[test] fn slash_thinking_when_disconnected_does_not_change_state() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.reasoning_effort = Some("high".into()); @@ -1119,7 +1119,7 @@ mod external_editor_tests { #[test] fn slash_fork_sends_latest_boundary() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); app.input = "/fork".into(); app.input_cursor = "/fork".len(); @@ -1133,14 +1133,14 @@ mod external_editor_tests { assert!(matches!( rx.try_recv().expect("ForkSession sent"), - ClientMsg::ForkSession { message_id } if message_id == "user-2" + Command::ForkSession { message_id } if message_id == "user-2" )); assert_eq!(app.pending_fork_message_id.as_deref(), Some("user-2")); } #[test] fn ctrl_x_f_opens_fork_popup_and_filter_captures_text() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); handle_key(&mut app, ctrl_x(), &tx).unwrap(); @@ -1155,7 +1155,7 @@ mod external_editor_tests { #[test] fn ctrl_x_f_in_delegate_view_does_not_open_fork_popup() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); app.screen = Screen::Delegate; @@ -1170,7 +1170,7 @@ mod external_editor_tests { #[test] fn slash_fork_in_delegate_view_sends_nothing() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); app.screen = Screen::Delegate; app.input = "/fork".into(); @@ -1191,7 +1191,7 @@ mod external_editor_tests { #[test] fn fork_popup_enter_sends_selected_turn() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); app.open_fork_turn_popup(); app.fork_filter = "alpha".into(); @@ -1205,14 +1205,14 @@ mod external_editor_tests { assert!(matches!( rx.try_recv().expect("ForkSession sent"), - ClientMsg::ForkSession { message_id } if message_id == "asst-1" + Command::ForkSession { message_id } if message_id == "asst-1" )); assert_eq!(app.pending_fork_message_id.as_deref(), Some("asst-1")); } #[test] fn fork_popup_enter_with_default_cursor_sends_latest_visible_turn() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = app_with_forkable_messages(); app.open_fork_turn_popup(); @@ -1225,14 +1225,14 @@ mod external_editor_tests { assert!(matches!( rx.try_recv().expect("ForkSession sent"), - ClientMsg::ForkSession { message_id } if message_id == "user-2" + Command::ForkSession { message_id } if message_id == "user-2" )); assert_eq!(app.pending_fork_message_id.as_deref(), Some("user-2")); } #[test] fn fork_popup_enter_with_no_eligible_turns_sends_nothing() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1251,7 +1251,7 @@ mod external_editor_tests { #[test] fn slash_model_with_arg_prefilters_popup() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1273,7 +1273,7 @@ mod external_editor_tests { #[test] fn slash_model_no_arg_opens_popup_unfiltered() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1294,7 +1294,7 @@ mod external_editor_tests { #[test] fn chat_double_esc_cancels_running_tool_phase() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.activity = ActivityState::RunningTool { @@ -1317,7 +1317,7 @@ mod external_editor_tests { .unwrap(); assert!(matches!( rx.try_recv().expect("cancel sent"), - ClientMsg::CancelSession + Command::CancelSession )); assert_eq!(app.status, "stopping..."); assert!(matches!( @@ -1328,7 +1328,7 @@ mod external_editor_tests { #[test] fn chat_input_is_blocked_while_undo_is_pending() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1372,7 +1372,7 @@ mod external_editor_tests { #[test] fn chat_input_is_blocked_while_cancel_confirm_is_active() { - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -1417,7 +1417,7 @@ mod external_editor_tests { assert_eq!(app.status, "stopping..."); assert!(matches!( rx.try_recv().expect("cancel sent"), - ClientMsg::CancelSession + Command::CancelSession )); } } @@ -1463,7 +1463,7 @@ pub async fn run() -> anyhow::Result<()> { // channels for the event loop let (srv_tx, mut srv_rx) = mpsc::unbounded_channel::(); - let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); let (conn_tx, mut conn_rx) = mpsc::unbounded_channel::(); let mut app = App::new(); @@ -1616,9 +1616,9 @@ impl PersistenceGuard { #[cfg(test)] mod sessions_key_tests { use super::*; + use crate::command::Command; use crate::domain::session::{SessionGroup, SessionSummary}; use crate::handlers::*; - use crate::protocol::ClientMsg; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc; @@ -1717,19 +1717,32 @@ mod sessions_key_tests { // ── Enter on Session loads it ───────────────────────────────────────────── #[test] - fn enter_on_session_returns_load_action() { + fn enter_on_session_emits_one_load_and_one_subscribe() { let mut app = App::new(); + app.conn = app::ConnState::Connected; + app.agent_id = Some("agent-1".into()); app.session_groups = vec![make_group(Some("/a"), &["abc12345"])]; - app.session_cursor = 1; // Session row - let action = apply_sessions_key(&mut app, KeyCode::Enter); - assert_eq!( - action, - SessionKeyAction::LoadSession { - session_id: "abc12345".to_string(), - agent_id: None, - cwd: Some("/a".to_string()), - } - ); + app.session_cursor = 1; + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_sessions_key( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &tx, + ) + .unwrap(); + + assert!(matches!( + rx.try_recv(), + Ok(Command::LoadSession { session_id, cwd: Some(cwd) }) + if session_id == "abc12345" && cwd == "/a" + )); + assert!(matches!( + rx.try_recv(), + Ok(Command::SubscribeSession { session_id, agent_id }) + if session_id == "abc12345" && agent_id.as_deref() == Some("agent-1") + )); + assert!(rx.try_recv().is_err()); } #[test] @@ -1820,7 +1833,7 @@ mod sessions_key_tests { assert!(app.expanded_session_children.contains("root")); assert!(matches!( cmd_rx.try_recv(), - Ok(ClientMsg::ListSessionChildren { + Ok(Command::ListSessionChildren { parent_session_id, .. }) if parent_session_id == "root" @@ -2055,9 +2068,9 @@ mod sessions_key_tests { mod session_popup_key_tests { use super::*; use crate::app::Popup; + use crate::command::Command; use crate::domain::session::{SessionGroup, SessionSummary}; use crate::handlers::*; - use crate::protocol::ClientMsg; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; fn make_group(cwd: Option<&str>, ids: &[&str]) -> SessionGroup { @@ -2325,7 +2338,7 @@ mod session_popup_key_tests { assert_eq!(app.popup, Popup::SessionSelect); assert!(matches!( cmd_rx.try_recv(), - Ok(ClientMsg::ListSessionChildren { + Ok(Command::ListSessionChildren { parent_session_id, .. }) if parent_session_id == "root" @@ -2661,9 +2674,8 @@ mod session_popup_key_tests { assert_eq!(app.popup, Popup::None); assert!(matches!( rx.try_recv(), - Ok(ClientMsg::NewSession { + Ok(Command::NewSession { cwd: Some(ref cwd), - request_id: None, profile_id: None }) if cwd == "/launch" )); @@ -2688,9 +2700,8 @@ mod session_popup_key_tests { assert!(matches!( rx.try_recv(), - Ok(ClientMsg::NewSession { + Ok(Command::NewSession { cwd: Some(ref cwd), - request_id: None, profile_id: None }) if cwd == "/launch/proj/subdir" )); @@ -3028,7 +3039,7 @@ mod chord_reasoning_effort_tests { #[serial] fn ctrl_t_cycles_effort_and_sends_msg() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; assert_eq!(app.reasoning_effort, None); @@ -3038,7 +3049,7 @@ mod chord_reasoning_effort_tests { assert_eq!(app.reasoning_effort, Some("low".into())); let msg = rx.try_recv().expect("expected SetReasoningEffort message"); match msg { - ClientMsg::SetReasoningEffort { reasoning_effort } => { + Command::SetReasoningEffort { reasoning_effort } => { assert_eq!(reasoning_effort, "low"); } other => panic!("unexpected message: {other:?}"), @@ -3049,7 +3060,7 @@ mod chord_reasoning_effort_tests { #[serial] fn ctrl_t_full_cycle_sends_auto_on_wrap() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; app.reasoning_effort = Some("max".into()); @@ -3059,7 +3070,7 @@ mod chord_reasoning_effort_tests { assert_eq!(app.reasoning_effort, None); let msg = rx.try_recv().expect("expected SetReasoningEffort message"); match msg { - ClientMsg::SetReasoningEffort { reasoning_effort } => { + Command::SetReasoningEffort { reasoning_effort } => { assert_eq!(reasoning_effort, "auto"); } other => panic!("unexpected message: {other:?}"), @@ -3070,7 +3081,7 @@ mod chord_reasoning_effort_tests { #[serial] fn ctrl_t_status_updated() { let _guard = PersistenceGuard::new("main-test"); - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; handle_key(&mut app, ctrl_t(), &tx).unwrap(); @@ -3084,7 +3095,7 @@ mod chord_reasoning_effort_tests { #[test] fn ctrl_t_when_disconnected_does_not_change_state() { - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.reasoning_effort = Some("high".into()); @@ -3129,7 +3140,7 @@ mod reasoning_effort_integration_tests { #[serial] fn ctrl_t_cycles_reasoning_effort() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; @@ -3143,7 +3154,7 @@ mod reasoning_effort_integration_tests { assert_eq!(app.reasoning_effort, Some("low".into())); assert!(matches!( rx.try_recv(), - Ok(ClientMsg::SetReasoningEffort { reasoning_effort }) if reasoning_effort == "low" + Ok(Command::SetReasoningEffort { reasoning_effort }) if reasoning_effort == "low" )); } @@ -3151,7 +3162,7 @@ mod reasoning_effort_integration_tests { #[serial] fn tab_switches_mode_without_changing_model_or_effort() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; app.session_id = Some("s1".into()); @@ -3169,13 +3180,13 @@ mod reasoning_effort_integration_tests { let msgs: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); assert!( msgs.iter() - .any(|m| matches!(m, ClientMsg::SetAgentMode { mode } if mode == "plan")), + .any(|m| matches!(m, Command::SetAgentMode { mode } if mode == "plan")), "expected SetAgentMode(plan): {msgs:?}" ); assert!( !msgs .iter() - .any(|m| matches!(m, ClientMsg::SetReasoningEffort { .. })), + .any(|m| matches!(m, Command::SetReasoningEffort { .. })), "no effort restore on mode switch: {msgs:?}" ); } @@ -3184,7 +3195,7 @@ mod reasoning_effort_integration_tests { #[serial] fn tab_no_cache_entry_leaves_model_and_effort_unchanged() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; app.session_id = Some("s1".into()); @@ -3204,7 +3215,7 @@ mod reasoning_effort_integration_tests { assert!( !msgs .iter() - .any(|m| matches!(m, ClientMsg::SetReasoningEffort { .. })), + .any(|m| matches!(m, Command::SetReasoningEffort { .. })), "no SetReasoningEffort expected: {msgs:?}" ); } @@ -3215,7 +3226,7 @@ mod reasoning_effort_integration_tests { #[serial] fn ctrl_x_m_opens_model_popup_at_current_mode_model() { let _guard = PersistenceGuard::new("main-test"); - let (tx, _rx) = mpsc::unbounded_channel::(); + let (tx, _rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.screen = Screen::Chat; app.conn = app::ConnState::Connected; @@ -3252,7 +3263,7 @@ mod reasoning_effort_integration_tests { #[serial] fn model_select_drops_effort_to_auto() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; app.session_id = Some("s1".into()); @@ -3282,7 +3293,7 @@ mod reasoning_effort_integration_tests { assert!( msgs.iter().any(|m| matches!( m, - ClientMsg::SetReasoningEffort { reasoning_effort } + Command::SetReasoningEffort { reasoning_effort } if reasoning_effort == "auto" )), "expected SetReasoningEffort(auto): {msgs:?}" @@ -3293,7 +3304,7 @@ mod reasoning_effort_integration_tests { #[serial] fn model_select_no_effort_msg_when_already_auto() { let _guard = PersistenceGuard::new("main-test"); - let (tx, mut rx) = mpsc::unbounded_channel::(); + let (tx, mut rx) = mpsc::unbounded_channel::(); let mut app = App::new(); app.conn = app::ConnState::Connected; app.session_id = Some("s1".into()); @@ -3321,7 +3332,7 @@ mod reasoning_effort_integration_tests { assert!( !msgs .iter() - .any(|m| matches!(m, ClientMsg::SetReasoningEffort { .. })), + .any(|m| matches!(m, Command::SetReasoningEffort { .. })), "no SetReasoningEffort when already auto: {msgs:?}" ); } @@ -3357,7 +3368,6 @@ mod auth_tests { AuthProviderEntry, OAuthFlow, OAuthFlowKind, OAuthResult, OAuthResultStatus, OAuthStatus, }; use crate::handlers::*; - use crate::protocol::ClientMsg; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; fn key(code: KeyCode) -> KeyEvent { @@ -3626,7 +3636,7 @@ mod auth_tests { handle_auth_popup_key(&mut app, key(KeyCode::Enter), &tx).unwrap(); assert_eq!(app.auth_selected, Some(0)); let msg = rx.try_recv().expect("message sent"); - assert!(matches!(msg, ClientMsg::StartOAuthLogin { provider } if provider == "codex")); + assert!(matches!(msg, Command::StartOAuthLogin { provider } if provider == "codex")); assert!(app.auth_ui_notice.is_none()); } @@ -3689,7 +3699,7 @@ mod auth_tests { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); handle_auth_popup_key(&mut app, ctrl('o'), &tx).unwrap(); let msg = rx.try_recv().expect("message sent"); - assert!(matches!(msg, ClientMsg::StartOAuthLogin { provider } if provider == "openai")); + assert!(matches!(msg, Command::StartOAuthLogin { provider } if provider == "openai")); assert!(app.auth_ui_notice.is_none()); } @@ -3711,7 +3721,7 @@ mod auth_tests { let msg = rx.try_recv().expect("message sent"); assert!(matches!( msg, - ClientMsg::SetApiToken { provider, api_key } + Command::SetApiToken { provider, api_key } if provider == "groq" && api_key == "sk" )); } @@ -3766,7 +3776,7 @@ mod auth_tests { handle_auth_popup_key(&mut app, ctrl('d'), &tx).unwrap(); let msg = rx.try_recv().expect("message sent"); - assert!(matches!(msg, ClientMsg::ClearApiToken { provider } if provider == "groq")); + assert!(matches!(msg, Command::ClearApiToken { provider } if provider == "groq")); } #[test] @@ -3823,7 +3833,7 @@ mod auth_tests { let msg = rx.try_recv().expect("message sent"); assert!(matches!( msg, - ClientMsg::CompleteOAuthLogin { flow_id, response } + Command::CompleteOAuthLogin { flow_id, response } if flow_id == "f1" && response == "code" )); } @@ -3845,7 +3855,7 @@ mod auth_tests { let msg = rx.try_recv().expect("message sent"); assert!(matches!( msg, - ClientMsg::CompleteOAuthLogin { flow_id, response } + Command::CompleteOAuthLogin { flow_id, response } if flow_id == "f1" && response.is_empty() )); } @@ -4015,7 +4025,7 @@ mod auth_tests { let msg = rx.try_recv().expect("message sent"); assert!(matches!( msg, - ClientMsg::DisconnectOAuth { provider } if provider == "openai" + Command::DisconnectOAuth { provider } if provider == "openai" )); } @@ -4031,7 +4041,7 @@ mod auth_tests { let msg = rx.try_recv().expect("message sent"); assert!(matches!( msg, - ClientMsg::ClearApiToken { provider } if provider == "groq" + Command::ClearApiToken { provider } if provider == "groq" )); } @@ -4071,7 +4081,7 @@ mod auth_tests { handle_auth_popup_key(&mut app, ctrl('d'), &tx).unwrap(); let msg = rx.try_recv().expect("message sent"); // Should disconnect OAuth first, not clear API key - assert!(matches!(msg, ClientMsg::DisconnectOAuth { .. })); + assert!(matches!(msg, Command::DisconnectOAuth { .. })); } // ── Clipboard copy tests ──────────────────────────────────────────────── @@ -4139,6 +4149,6 @@ mod auth_tests { assert!(!app.chord); let msg = rx.try_recv().expect("message sent"); - assert!(matches!(msg, ClientMsg::ListAuthProviders)); + assert!(matches!(msg, Command::ListAuthProviders)); } } From 62ebe48ec62c6a4389e223ac01f25f741d6ca8eb Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Sun, 9 Aug 2026 23:47:02 +0100 Subject: [PATCH 2/2] fix: remove orphan pending command queue --- src/acp_state.rs | 37 ++----------------------------------- src/app.rs | 8 -------- 2 files changed, 2 insertions(+), 43 deletions(-) diff --git a/src/acp_state.rs b/src/acp_state.rs index b19c454..fc13a41 100644 --- a/src/acp_state.rs +++ b/src/acp_state.rs @@ -343,7 +343,7 @@ impl crate::app::App { is_replay, } => { self.apply_acp_session_update(&session_id, update, is_replay); - self.drain_pending_commands() + vec![] } AcpAppEvent::SessionReplay { session_id, @@ -358,7 +358,7 @@ impl crate::app::App { for update in updates { self.apply_acp_session_update(&session_id, update, true); } - self.drain_pending_commands() + vec![] } AcpAppEvent::UndoStack(undo_stack) => { self.undo_state = self.build_undo_state_from_server_stack(&undo_stack, None, None); @@ -3817,39 +3817,6 @@ mod tests { assert!(app.can_redo()); } - #[test] - fn session_update_and_replay_drain_pending_commands() { - let mut app = app_with_active_session(); - app.pending_commands.push(Command::SubscribeSession { - session_id: "child-1".into(), - agent_id: Some("agent-1".into()), - }); - - let update_commands = app.handle_acp_event(AcpAppEvent::SessionUpdate { - session_id: TEST_SESSION_ID.into(), - is_replay: false, - update: AcpSessionUpdate::TurnStarted, - }); - - assert_eq!( - update_commands, - vec![Command::SubscribeSession { - session_id: "child-1".into(), - agent_id: Some("agent-1".into()), - }] - ); - assert!(app.pending_commands.is_empty()); - - app.pending_commands.push(Command::GetFileIndex); - let replay_commands = app.handle_acp_event(AcpAppEvent::SessionReplay { - session_id: TEST_SESSION_ID.into(), - updates: Vec::new(), - }); - - assert_eq!(replay_commands, vec![Command::GetFileIndex]); - assert!(app.pending_commands.is_empty()); - } - #[test] fn native_session_update_for_other_session_marks_activity_only() { let mut app = App::new(); diff --git a/src/app.rs b/src/app.rs index d11f82b..3d50193 100644 --- a/src/app.rs +++ b/src/app.rs @@ -767,9 +767,6 @@ pub struct App { /// Set after DelegationCompleted/DelegationFailed; consumed by the next /// UserMessageStored to suppress the noisy batch-result message. pub suppress_delegation_result: bool, - /// Commands queued by event handlers (e.g. SubscribeSession for child sessions). - /// Drained by native ACP after each event/replay batch. - pub pending_commands: Vec, /// Child-session state observed before a delegation entry can be linked. pub pending_delegate_child_states: HashMap, pub pending_delegate_child_stats: HashMap, @@ -814,10 +811,6 @@ fn move_wrapping_cursor(cursor: usize, len: usize, delta: isize) -> usize { } impl App { - pub(crate) fn drain_pending_commands(&mut self) -> Vec { - std::mem::take(&mut self.pending_commands) - } - pub fn begin_session_discovery(&mut self) -> Option { if self.session_discovery_in_progress || !self.pending_session_group_loads.is_empty() { return None; @@ -990,7 +983,6 @@ impl App { parent_session_id: None, pending_parent_session_id: None, suppress_delegation_result: false, - pending_commands: Vec::new(), pending_delegate_child_states: HashMap::new(), pending_delegate_child_stats: HashMap::new(), delegate_child_message_ids: HashMap::new(),