diff --git a/justfile b/justfile new file mode 100644 index 00000000..026cd351 --- /dev/null +++ b/justfile @@ -0,0 +1,8 @@ +# Format code +fmt: + cargo fmt + +# Run all lints (format check + clippy) +lint: + cargo fmt --check + cargo clippy -- -D warnings diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..edee90b4 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +# Use rustfmt defaults. This file exists so contributors and CI know +# the project is formatted with `cargo fmt`. diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 9fc5a1f1..bf67df5e 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -90,13 +90,16 @@ impl AcpConnection { // Auto-reply session/request_permission if msg.method.as_deref() == Some("session/request_permission") { if let Some(id) = msg.id { - let title = msg.params.as_ref() + let title = msg + .params + .as_ref() .and_then(|p| p.get("toolCall")) .and_then(|t| t.get("title")) .and_then(|t| t.as_str()) .unwrap_or("?"); info!(title, "auto-allow permission"); - let reply = JsonRpcResponse::new(id, json!({"optionId": "allow_always"})); + let reply = + JsonRpcResponse::new(id, json!({"optionId": "allow_always"})); if let Ok(data) = serde_json::to_string(&reply) { let mut w = stdin_clone.lock().await; let _ = w.write_all(format!("{data}\n").as_bytes()).await; @@ -214,7 +217,9 @@ impl AcpConnection { ) .await?; - let agent_name = resp.result.as_ref() + let agent_name = resp + .result + .as_ref() .and_then(|r| r.get("agentInfo")) .and_then(|a| a.get("name")) .and_then(|n| n.as_str()) @@ -225,13 +230,12 @@ impl AcpConnection { pub async fn session_new(&mut self, cwd: &str) -> Result { let resp = self - .send_request( - "session/new", - Some(json!({"cwd": cwd, "mcpServers": []})), - ) + .send_request("session/new", Some(json!({"cwd": cwd, "mcpServers": []}))) .await?; - let session_id = resp.result.as_ref() + let session_id = resp + .result + .as_ref() .and_then(|r| r.get("sessionId")) .and_then(|s| s.as_str()) .ok_or_else(|| anyhow!("no sessionId in session/new response"))? diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a2c8a06c..2673e79c 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -71,7 +71,10 @@ impl SessionPool { /// Get mutable access to a connection. Caller must have called get_or_create first. pub async fn with_connection(&self, thread_id: &str, f: F) -> Result where - F: FnOnce(&mut AcpConnection) -> std::pin::Pin> + Send + '_>>, + F: FnOnce( + &mut AcpConnection, + ) + -> std::pin::Pin> + Send + '_>>, { let mut conns = self.connections.write().await; let conn = conns diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index d3e96ed5..53641d65 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -14,7 +14,12 @@ pub struct JsonRpcRequest { impl JsonRpcRequest { pub fn new(id: u64, method: impl Into, params: Option) -> Self { - Self { jsonrpc: "2.0", id, method: method.into(), params } + Self { + jsonrpc: "2.0", + id, + method: method.into(), + params, + } } } @@ -27,7 +32,11 @@ pub struct JsonRpcResponse { impl JsonRpcResponse { pub fn new(id: u64, result: Value) -> Self { - Self { jsonrpc: "2.0", id, result } + Self { + jsonrpc: "2.0", + id, + result, + } } } @@ -75,16 +84,26 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { let text = update.get("content")?.get("text")?.as_str()?; Some(AcpEvent::Text(text.to_string())) } - "agent_thought_chunk" => { - Some(AcpEvent::Thinking) - } + "agent_thought_chunk" => Some(AcpEvent::Thinking), "tool_call" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); Some(AcpEvent::ToolStart { title }) } "tool_call_update" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let status = update + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if status == "completed" || status == "failed" { Some(AcpEvent::ToolDone { title, status }) } else { diff --git a/src/config.rs b/src/config.rs index 719feafa..79ffe660 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,28 +85,63 @@ pub struct ReactionTiming { // --- defaults --- -fn default_working_dir() -> String { "/tmp".into() } -fn default_max_sessions() -> usize { 10 } -fn default_ttl_hours() -> u64 { 24 } -fn default_true() -> bool { true } - -fn emoji_queued() -> String { "👀".into() } -fn emoji_thinking() -> String { "🤔".into() } -fn emoji_tool() -> String { "🔥".into() } -fn emoji_coding() -> String { "👨‍💻".into() } -fn emoji_web() -> String { "⚡".into() } -fn emoji_done() -> String { "🆗".into() } -fn emoji_error() -> String { "😱".into() } - -fn default_debounce_ms() -> u64 { 700 } -fn default_stall_soft_ms() -> u64 { 10_000 } -fn default_stall_hard_ms() -> u64 { 30_000 } -fn default_done_hold_ms() -> u64 { 1_500 } -fn default_error_hold_ms() -> u64 { 2_500 } +fn default_working_dir() -> String { + "/tmp".into() +} +fn default_max_sessions() -> usize { + 10 +} +fn default_ttl_hours() -> u64 { + 24 +} +fn default_true() -> bool { + true +} + +fn emoji_queued() -> String { + "👀".into() +} +fn emoji_thinking() -> String { + "🤔".into() +} +fn emoji_tool() -> String { + "🔥".into() +} +fn emoji_coding() -> String { + "👨‍💻".into() +} +fn emoji_web() -> String { + "⚡".into() +} +fn emoji_done() -> String { + "🆗".into() +} +fn emoji_error() -> String { + "😱".into() +} + +fn default_debounce_ms() -> u64 { + 700 +} +fn default_stall_soft_ms() -> u64 { + 10_000 +} +fn default_stall_hard_ms() -> u64 { + 30_000 +} +fn default_done_hold_ms() -> u64 { + 1_500 +} +fn default_error_hold_ms() -> u64 { + 2_500 +} impl Default for PoolConfig { fn default() -> Self { - Self { max_sessions: default_max_sessions(), session_ttl_hours: default_ttl_hours() } + Self { + max_sessions: default_max_sessions(), + session_ttl_hours: default_ttl_hours(), + } } } @@ -124,8 +159,13 @@ impl Default for ReactionsConfig { impl Default for ReactionEmojis { fn default() -> Self { Self { - queued: emoji_queued(), thinking: emoji_thinking(), tool: emoji_tool(), - coding: emoji_coding(), web: emoji_web(), done: emoji_done(), error: emoji_error(), + queued: emoji_queued(), + thinking: emoji_thinking(), + tool: emoji_tool(), + coding: emoji_coding(), + web: emoji_web(), + done: emoji_done(), + error: emoji_error(), } } } @@ -133,8 +173,10 @@ impl Default for ReactionEmojis { impl Default for ReactionTiming { fn default() -> Self { Self { - debounce_ms: default_debounce_ms(), stall_soft_ms: default_stall_soft_ms(), - stall_hard_ms: default_stall_hard_ms(), done_hold_ms: default_done_hold_ms(), + debounce_ms: default_debounce_ms(), + stall_soft_ms: default_stall_soft_ms(), + stall_hard_ms: default_stall_hard_ms(), + done_hold_ms: default_done_hold_ms(), error_hold_ms: default_error_hold_ms(), } } diff --git a/src/discord.rs b/src/discord.rs index da52c691..33b469bc 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -33,7 +33,10 @@ impl EventHandler for Handler { let is_mentioned = msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) - || msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r))); + || msg + .mention_roles + .iter() + .any(|r| msg.content.contains(&format!("<@&{}>", r))); let in_thread = if !in_allowed_channel { match msg.channel_id.to_channel(&ctx.http).await { @@ -74,7 +77,9 @@ impl EventHandler for Handler { } // Inject structured sender context so the downstream CLI can identify who sent the message - let display_name = msg.member.as_ref() + let display_name = msg + .member + .as_ref() .and_then(|m| m.nick.as_ref()) .unwrap_or(&msg.author.name); let sender_ctx = serde_json::json!({ @@ -118,7 +123,13 @@ impl EventHandler for Handler { let thread_key = thread_id.to_string(); if let Err(e) = self.pool.get_or_create(&thread_key).await { - let _ = edit(&ctx, thread_channel, thinking_msg.id, "⚠️ Failed to start agent.").await; + let _ = edit( + &ctx, + thread_channel, + thinking_msg.id, + "⚠️ Failed to start agent.", + ) + .await; error!("pool error: {e}"); return; } @@ -175,8 +186,18 @@ impl EventHandler for Handler { } } -async fn edit(ctx: &Context, ch: ChannelId, msg_id: MessageId, content: &str) -> serenity::Result { - ch.edit_message(&ctx.http, msg_id, serenity::builder::EditMessage::new().content(content)).await +async fn edit( + ctx: &Context, + ch: ChannelId, + msg_id: MessageId, + content: &str, +) -> serenity::Result { + ch.edit_message( + &ctx.http, + msg_id, + serenity::builder::EditMessage::new().content(content), + ) + .await } async fn stream_prompt( @@ -280,7 +301,9 @@ async fn stream_prompt( AcpEvent::ToolDone { title, status, .. } => { reactions.set_thinking().await; let icon = if status == "completed" { "✅" } else { "❌" }; - if let Some(line) = tool_lines.iter_mut().rev().find(|l| l.contains(&title)) { + if let Some(line) = + tool_lines.iter_mut().rev().find(|l| l.contains(&title)) + { *line = format!("{icon} `{title}`"); } let _ = buf_tx.send(compose_display(&tool_lines, &text_buf)); diff --git a/src/main.rs b/src/main.rs index a216b668..93f4a872 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,9 +49,8 @@ async fn main() -> anyhow::Result<()> { reactions_config: cfg.reactions, }; - let intents = GatewayIntents::GUILD_MESSAGES - | GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::GUILDS; + let intents = + GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILDS; let mut client = Client::builder(&cfg.discord.bot_token, intents) .event_handler(handler) diff --git a/src/reactions.rs b/src/reactions.rs index 683d334b..1b3c51ec 100644 --- a/src/reactions.rs +++ b/src/reactions.rs @@ -7,7 +7,13 @@ use tokio::sync::Mutex; use tokio::time::Duration; const CODING_TOKENS: &[&str] = &["exec", "process", "read", "write", "edit", "bash", "shell"]; -const WEB_TOKENS: &[&str] = &["web_search", "web_fetch", "web-search", "web-fetch", "browser"]; +const WEB_TOKENS: &[&str] = &[ + "web_search", + "web_fetch", + "web-search", + "web-fetch", + "browser", +]; fn classify_tool<'a>(name: &str, emojis: &'a ReactionEmojis) -> &'a str { let n = name.to_lowercase(); @@ -65,19 +71,25 @@ impl StatusReactionController { } pub async fn set_queued(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.queued.clone() }; self.apply_immediate(&emoji).await; } pub async fn set_thinking(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.thinking.clone() }; self.schedule_debounced(&emoji).await; } pub async fn set_tool(&self, tool_name: &str) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { let inner = self.inner.lock().await; classify_tool(tool_name, &inner.emojis).to_string() @@ -86,7 +98,9 @@ impl StatusReactionController { } pub async fn set_done(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.done.clone() }; self.finish(&emoji).await; // Add a random mood face @@ -97,13 +111,17 @@ impl StatusReactionController { } pub async fn set_error(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.error.clone() }; self.finish(&emoji).await; } pub async fn clear(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let mut inner = self.inner.lock().await; cancel_timers(&mut inner); let current = inner.current.clone(); @@ -148,7 +166,9 @@ impl StatusReactionController { inner.debounce_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(debounce_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = emoji.clone(); let http = inner.http.clone(); @@ -166,7 +186,9 @@ impl StatusReactionController { async fn finish(&self, emoji: &str) { let mut inner = self.inner.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } inner.finished = true; cancel_timers(&mut inner); @@ -190,8 +212,12 @@ impl StatusReactionController { } fn reset_stall_timers_inner(&self, inner: &mut Inner) { - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } let soft_ms = inner.timing.stall_soft_ms; let hard_ms = inner.timing.stall_hard_ms; @@ -202,7 +228,9 @@ impl StatusReactionController { async move { tokio::time::sleep(Duration::from_millis(soft_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "🥱".to_string(); let http = inner.http.clone(); @@ -219,7 +247,9 @@ impl StatusReactionController { inner.stall_hard_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(hard_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "😨".to_string(); let http = inner.http.clone(); @@ -235,21 +265,39 @@ impl StatusReactionController { } fn cancel_debounce(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } } fn cancel_timers(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } } -async fn add_reaction(http: &Http, ch: ChannelId, msg: MessageId, emoji: &str) -> serenity::Result<()> { +async fn add_reaction( + http: &Http, + ch: ChannelId, + msg: MessageId, + emoji: &str, +) -> serenity::Result<()> { let reaction = ReactionType::Unicode(emoji.to_string()); http.create_reaction(ch, msg, &reaction).await } -async fn remove_reaction(http: &Http, ch: ChannelId, msg: MessageId, emoji: &str) -> serenity::Result<()> { +async fn remove_reaction( + http: &Http, + ch: ChannelId, + msg: MessageId, + emoji: &str, +) -> serenity::Result<()> { let reaction = ReactionType::Unicode(emoji.to_string()); http.delete_reaction_me(ch, msg, &reaction).await }