diff --git a/src/discord.rs b/src/discord.rs index 5f9c2e74..7b4708ad 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -7,7 +7,8 @@ use crate::format; use crate::media; use async_trait::async_trait; use std::sync::LazyLock; -use serenity::builder::{CreateActionRow, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage}; +use serenity::builder::{CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage}; +use serenity::model::application::ButtonStyle; use serenity::http::Http; use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType}; @@ -25,6 +26,9 @@ const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10; /// Maximum entries in the participation cache before eviction. const PARTICIPATION_CACHE_MAX: usize = 1000; +/// Discord StringSelectMenu hard limit on options. +const SELECT_MENU_PAGE_SIZE: usize = 25; + // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- pub struct DiscordAdapter { @@ -688,6 +692,9 @@ impl EventHandler for Handler { Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { self.handle_config_select(&ctx, &comp).await; } + Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_pg:") => { + self.handle_pagination(&ctx, &comp).await; + } _ => {} } } @@ -698,21 +705,22 @@ impl EventHandler for Handler { impl Handler { /// Build a Discord select menu from ACP configOptions with the given category. - /// Discord limits Select Menus to 25 options; excess options are truncated. - /// The currently selected option is always included (placed first if needed). - fn build_config_select(options: &[ConfigOption], category: &str) -> Option { + /// Paginates options in pages of 25 (Discord limit). The current selection is + /// always placed first so it appears on page 0. + fn build_config_select(options: &[ConfigOption], category: &str, page: usize) -> Option { let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; - // Ensure current selection is in the first 25 by placing it first, - // then filling remaining slots with the rest in original order. + // Put current selection first so it always lands on page 0, + // then fill remaining slots in original order. let sorted: Vec<_> = opt.options.iter() .filter(|o| o.value == opt.current_value) .chain(opt.options.iter().filter(|o| o.value != opt.current_value)) - .take(25) .collect(); let menu_options: Vec = sorted .iter() + .skip(page * SELECT_MENU_PAGE_SIZE) + .take(SELECT_MENU_PAGE_SIZE) .map(|o| { let mut item = CreateSelectMenuOption::new(&o.name, &o.value); if let Some(desc) = &o.description { @@ -729,15 +737,16 @@ impl Handler { return None; } - let truncated = opt.options.len() > 25; - let placeholder = format!( - "Current: {}{}", - opt.options.iter() - .find(|o| o.value == opt.current_value) - .map(|o| o.name.as_str()) - .unwrap_or(&opt.current_value), - if truncated { format!(" ({} more not shown)", opt.options.len() - 25) } else { String::new() }, - ); + let current_name = opt.options.iter() + .find(|o| o.value == opt.current_value) + .map(|o| o.name.as_str()) + .unwrap_or(&opt.current_value); + let total_pages = sorted.len().div_ceil(SELECT_MENU_PAGE_SIZE); + let placeholder = if total_pages > 1 { + format!("Current: {} (page {}/{})", current_name, page + 1, total_pages) + } else { + format!("Current: {}", current_name) + }; Some( CreateSelectMenu::new( @@ -748,6 +757,47 @@ impl Handler { ) } + /// Build ◀/▶ pagination buttons. Returns None when only one page exists. + fn build_pagination_buttons(category: &str, page: usize, total_pages: usize) -> Option { + if total_pages <= 1 { + return None; + } + let prev = CreateButton::new(format!("acp_pg:{}:{}", category, page.saturating_sub(1))) + .label("◀") + .style(ButtonStyle::Secondary) + .disabled(page == 0); + let next = CreateButton::new(format!("acp_pg:{}:{}", category, page + 1)) + .label("▶") + .style(ButtonStyle::Secondary) + .disabled(page + 1 >= total_pages); + let indicator = CreateButton::new("acp_pg_noop") + .label(format!("{}/{}", page + 1, total_pages)) + .style(ButtonStyle::Secondary) + .disabled(true); + Some(CreateActionRow::Buttons(vec![prev, indicator, next])) + } + + /// Build the full component rows (select menu + optional pagination) for a config category. + /// When `page` is `None`, auto-selects the page containing the current value. + fn build_config_components(options: &[ConfigOption], category: &str, page: Option) -> Option> { + let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE); + let page = match page { + Some(p) => p.min(total_pages.saturating_sub(1)), + None => opt.options.iter() + .position(|o| o.value == opt.current_value) + .map(|i| i / SELECT_MENU_PAGE_SIZE) + .unwrap_or(0), + }; + + let select = Self::build_config_select(options, category, page)?; + let mut rows = vec![CreateActionRow::SelectMenu(select)]; + if let Some(buttons) = Self::build_pagination_buttons(category, page, total_pages) { + rows.push(buttons); + } + Some(rows) + } + async fn handle_config_command( &self, ctx: &Context, @@ -757,13 +807,12 @@ impl Handler { ) { let thread_key = format!("discord:{}", cmd.channel_id.get()); let config_options = self.router.pool().get_config_options(&thread_key).await; - let select = Self::build_config_select(&config_options, category); - let response = match select { - Some(menu) => CreateInteractionResponse::Message( + let response = match Self::build_config_components(&config_options, category, None) { + Some(rows) => CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .content(format!("🔧 Select a {label}:")) - .components(vec![CreateActionRow::SelectMenu(menu)]) + .components(rows) .ephemeral(true), ), None => CreateInteractionResponse::Message( @@ -878,6 +927,47 @@ impl Handler { tracing::error!(error = %e, "failed to respond to config select"); } } + + async fn handle_pagination( + &self, + ctx: &Context, + comp: &serenity::model::application::ComponentInteraction, + ) { + // Parse custom_id format: acp_pg:{category}:{page} + let parts: Vec<&str> = comp.data.custom_id.splitn(3, ':').collect(); + let (category, page) = match parts.as_slice() { + [_, cat, pg] => match pg.parse::() { + Ok(p) => (*cat, p), + Err(_) => return, + }, + _ => return, + }; + + // Only allow known config categories. + if !matches!(category, "model" | "agent") { + return; + } + + let thread_key = format!("discord:{}", comp.channel_id.get()); + let config_options = self.router.pool().get_config_options(&thread_key).await; + + let response = match Self::build_config_components(&config_options, category, Some(page)) { + Some(rows) => CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .content(format!("🔧 Select a {category}:")) + .components(rows), + ), + None => CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ No {category} options available.")) + .components(vec![]), + ), + }; + + if let Err(e) = comp.create_response(&ctx.http, response).await { + tracing::error!(error = %e, category, "failed to respond to pagination"); + } + } } // --- Discord-specific helpers ---