notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit dcf5423e42cdf38bcdfa0eb29704319cce3b7389
parent 2e8ebb5352c4e466d15e153fb348b5c20bbd6635
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 13:01:54 -0800

refactor: extract shared helpers to reduce duplication in dave crate

- switch_and_focus_session(): consolidates switch_to/select/focus_on/focus_requested pattern (8 call sites)
- cycle_agent(): generic direction-based agent cycling, used by next/prev
- secret_key_bytes(): replaces 5 copies of keypair→secret_key→as_secret_bytes→try_into
- init_note_builder(): replaces duplicated NoteBuilder::new().kind().content().options() chains (6 call sites)
- finalize_built_event(): replaces duplicated sign/build/id/json pattern (6 call sites)
- now_secs(): replaces 4 copies of SystemTime::now timestamp boilerplate

Net: 128 insertions, 316 deletions across 3 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 76++++++++++++++++------------------------------------------------------------
Mcrates/notedeck_dave/src/session_events.rs | 207+++++++++++++++++++++++--------------------------------------------------------
Mcrates/notedeck_dave/src/ui/mod.rs | 176+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/notedeck_dave/src/update.rs | 161++++++++++++++++++++++++++-----------------------------------------------------
4 files changed, 193 insertions(+), 427 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -62,6 +62,15 @@ pub use vec3::Vec3; /// TODO: make this configurable in the UI const PNS_RELAY_URL: &str = "ws://relay.jb55.com"; +/// Extract a 32-byte secret key from a keypair. +fn secret_key_bytes(keypair: KeypairUnowned<'_>) -> Option<[u8; 32]> { + keypair.secret_key.map(|sk| { + sk.as_secret_bytes() + .try_into() + .expect("secret key is 32 bytes") + }) +} + /// Represents which full-screen overlay (if any) is currently active #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DaveOverlay { @@ -364,16 +373,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let active_id = self.session_manager.active_id(); // Extract secret key once for live event generation - let secret_key: Option<[u8; 32]> = app_ctx - .accounts - .get_selected_account() - .keypair() - .secret_key - .map(|sk| { - sk.as_secret_bytes() - .try_into() - .expect("secret key is 32 bytes") - }); + let secret_key = secret_key_bytes(app_ctx.accounts.get_selected_account().keypair()); // Get all session IDs to process let session_ids = self.session_manager.session_ids(); @@ -1066,18 +1066,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Publish kind-31988 state events for sessions whose status changed. fn publish_dirty_session_states(&mut self, ctx: &mut AppContext<'_>) { - let secret_key: Option<[u8; 32]> = ctx - .accounts - .get_selected_account() - .keypair() - .secret_key - .map(|sk| { - sk.as_secret_bytes() - .try_into() - .expect("secret key is 32 bytes") - }); - - let Some(sk) = secret_key else { + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { return; }; @@ -1130,18 +1119,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr return; } - let secret_key: Option<[u8; 32]> = ctx - .accounts - .get_selected_account() - .keypair() - .secret_key - .map(|sk| { - sk.as_secret_bytes() - .try_into() - .expect("secret key is 32 bytes") - }); - - let Some(sk) = secret_key else { + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { return; }; @@ -1175,18 +1153,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr return; } - let secret_key: Option<[u8; 32]> = ctx - .accounts - .get_selected_account() - .keypair() - .secret_key - .map(|sk| { - sk.as_secret_bytes() - .try_into() - .expect("secret key is 32 bytes") - }); - - let Some(sk) = secret_key else { + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { tracing::warn!("no secret key for publishing permission responses"); self.pending_perm_responses.clear(); return; @@ -1838,15 +1805,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session.input.clear(); // Generate live event for user message - if let Some(sk) = app_ctx - .accounts - .get_selected_account() - .keypair() - .secret_key - { - let sb = sk.as_secret_bytes(); - let secret_bytes: [u8; 32] = sb.try_into().expect("secret key is 32 bytes"); - if let Some(evt) = ingest_live_event(session, app_ctx.ndb, &secret_bytes, &user_text, "user", None) { + if let Some(sk) = secret_key_bytes(app_ctx.accounts.get_selected_account().keypair()) { + if let Some(evt) = ingest_live_event(session, app_ctx.ndb, &sk, &user_text, "user", None) { self.pending_relay_events.push(evt); } } @@ -2027,8 +1987,7 @@ impl notedeck::App for Dave { } } } else { - let keypair = ctx.accounts.get_selected_account().keypair(); - if let Some(sk) = keypair.secret_key { + if let Some(secret_bytes) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) { // Subscribe for 1988 events BEFORE ingesting so we catch them let sub_filter = nostrdb::Filter::new() .kinds([session_events::AI_CONVERSATION_KIND as u64]) @@ -2037,9 +1996,6 @@ impl notedeck::App for Dave { match ctx.ndb.subscribe(&[sub_filter]) { Ok(sub) => { - let sb = sk.as_secret_bytes(); - let secret_bytes: [u8; 32] = - sb.try_into().expect("secret key is 32 bytes"); match session_converter::convert_session_to_events( &file_path, ctx.ndb, diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -67,24 +67,10 @@ pub fn wrap_pns( let ciphertext = enostr::pns::encrypt(&pns_keys.conversation_key, inner_json) .map_err(|e| EventBuildError::Serialize(format!("PNS encrypt: {e}")))?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let pns_secret = pns_keys.keypair.secret_key.secret_bytes(); - - let note = NoteBuilder::new() - .kind(enostr::pns::PNS_KIND) - .content(&ciphertext) - .options(NoteBuildOptions::default()) - .created_at(now) - .sign(&pns_secret) - .build() - .ok_or_else(|| EventBuildError::Build("PNS NoteBuilder::build returned None".into()))?; - - note.json() - .map_err(|e| EventBuildError::Serialize(format!("PNS json: {e:?}"))) + let builder = init_note_builder(enostr::pns::PNS_KIND, &ciphertext, Some(now_secs())); + let event = finalize_built_event(builder, &pns_secret, enostr::pns::PNS_KIND)?; + Ok(event.note_json) } /// Maintains threading state across a session's events. @@ -175,6 +161,52 @@ impl ThreadingState { } } +/// Get the current Unix timestamp in seconds. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Initialize a NoteBuilder with kind, content, and optional timestamp. +fn init_note_builder(kind: u32, content: &str, timestamp: Option<u64>) -> NoteBuilder<'_> { + let mut builder = NoteBuilder::new() + .kind(kind) + .content(content) + .options(NoteBuildOptions::default()); + + if let Some(ts) = timestamp { + builder = builder.created_at(ts); + } + + builder +} + +/// Sign, build, and serialize a NoteBuilder into a BuiltEvent. +fn finalize_built_event( + builder: NoteBuilder, + secret_key: &[u8; 32], + kind: u32, +) -> Result<BuiltEvent, EventBuildError> { + let note = builder + .sign(secret_key) + .build() + .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; + + let note_id: [u8; 32] = *note.id(); + + let note_json = note + .json() + .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; + + Ok(BuiltEvent { + note_json, + note_id, + kind, + }) +} + /// Whether a role represents a conversation message (not metadata). pub fn is_conversation_role(role: &str) -> bool { matches!(role, "user" | "assistant" | "tool_call" | "tool_result") @@ -325,14 +357,7 @@ fn build_source_data_event( let raw_json = line.to_json(); let seq_str = seq.to_string(); - let mut builder = NoteBuilder::new() - .kind(AI_SOURCE_DATA_KIND) - .content("") - .options(NoteBuildOptions::default()); - - if let Some(ts) = timestamp { - builder = builder.created_at(ts); - } + let mut builder = init_note_builder(AI_SOURCE_DATA_KIND, "", timestamp); // Link to the corresponding 1988 event builder = builder @@ -353,22 +378,7 @@ fn build_source_data_event( .tag_str("source-data") .tag_str(&raw_json); - let note = builder - .sign(secret_key) - .build() - .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; - - let note_id: [u8; 32] = *note.id(); - - let note_json = note - .json() - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - Ok(BuiltEvent { - note_json, - note_id, - kind: AI_SOURCE_DATA_KIND, - }) + finalize_built_event(builder, secret_key, AI_SOURCE_DATA_KIND) } /// Build a single kind-1988 nostr event. @@ -394,14 +404,7 @@ fn build_single_event( threading: &ThreadingState, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - let mut builder = NoteBuilder::new() - .kind(AI_CONVERSATION_KIND) - .content(content) - .options(NoteBuildOptions::default()); - - if let Some(ts) = timestamp { - builder = builder.created_at(ts); - } + let mut builder = init_note_builder(AI_CONVERSATION_KIND, content, timestamp); // -- Session identity tags -- if let Some(session_id) = session_id { @@ -474,23 +477,7 @@ fn build_single_event( // -- Discoverability -- builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); - // Sign and build - let note = builder - .sign(secret_key) - .build() - .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; - - let note_id: [u8; 32] = *note.id(); - - let note_json = note - .json() - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - Ok(BuiltEvent { - note_json, - note_id, - kind: AI_CONVERSATION_KIND, - }) + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) } /// Build a kind-1988 event for a live conversation message. @@ -508,11 +495,6 @@ pub fn build_live_event( threading: &mut ThreadingState, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let event = build_single_event( None, content, @@ -522,7 +504,7 @@ pub fn build_live_event( tool_id, Some(session_id), cwd, - Some(now), + Some(now_secs()), threading, secret_key, )?; @@ -545,11 +527,6 @@ pub fn build_permission_request_event( session_id: &str, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - // Content is a JSON summary for display on remote clients let content = serde_json::json!({ "tool_name": tool_name, @@ -559,11 +536,7 @@ pub fn build_permission_request_event( let perm_id_str = perm_id.to_string(); - let mut builder = NoteBuilder::new() - .kind(AI_CONVERSATION_KIND) - .content(&content) - .options(NoteBuildOptions::default()) - .created_at(now); + let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); // Session identity builder = builder.start_tag().tag_str("d").tag_str(session_id); @@ -596,21 +569,7 @@ pub fn build_permission_request_event( .tag_str("t") .tag_str("ai-permission"); - let note = builder - .sign(secret_key) - .build() - .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; - - let note_id: [u8; 32] = *note.id(); - let note_json = note - .json() - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - Ok(BuiltEvent { - note_json, - note_id, - kind: AI_CONVERSATION_KIND, - }) + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) } /// Build a kind-1988 permission response event. @@ -629,11 +588,6 @@ pub fn build_permission_response_event( session_id: &str, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let content = serde_json::json!({ "decision": if allowed { "allow" } else { "deny" }, "message": message.unwrap_or(""), @@ -642,11 +596,7 @@ pub fn build_permission_response_event( let perm_id_str = perm_id.to_string(); - let mut builder = NoteBuilder::new() - .kind(AI_CONVERSATION_KIND) - .content(&content) - .options(NoteBuildOptions::default()) - .created_at(now); + let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); // Session identity builder = builder.start_tag().tag_str("d").tag_str(session_id); @@ -681,21 +631,7 @@ pub fn build_permission_response_event( .tag_str("t") .tag_str("ai-permission"); - let note = builder - .sign(secret_key) - .build() - .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; - - let note_id: [u8; 32] = *note.id(); - let note_json = note - .json() - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - Ok(BuiltEvent { - note_json, - note_id, - kind: AI_CONVERSATION_KIND, - }) + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) } /// Build a kind-31988 session state event (parameterized replaceable). @@ -710,16 +646,7 @@ pub fn build_session_state_event( status: &str, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let mut builder = NoteBuilder::new() - .kind(AI_SESSION_STATE_KIND) - .content("") - .options(NoteBuildOptions::default()) - .created_at(now); + let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs())); // Session identity (makes this a parameterized replaceable event) builder = builder.start_tag().tag_str("d").tag_str(claude_session_id); @@ -743,21 +670,7 @@ pub fn build_session_state_event( .tag_str("source") .tag_str("notedeck-dave"); - let note = builder - .sign(secret_key) - .build() - .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; - - let note_id: [u8; 32] = *note.id(); - let note_json = note - .json() - .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; - - Ok(BuiltEvent { - note_json, - note_id, - kind: AI_SESSION_STATE_KIND, - }) + finalize_built_event(builder, secret_key, AI_SESSION_STATE_KIND) } #[cfg(test)] diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -34,11 +34,59 @@ use crate::agent_status::AgentStatus; use crate::config::{AiMode, DaveSettings, ModelConfig}; use crate::focus_queue::FocusQueue; use crate::messages::PermissionResponse; -use crate::session::{PermissionMessageState, SessionId, SessionManager}; +use crate::session::{ChatSession, PermissionMessageState, SessionId, SessionManager}; use crate::session_discovery::discover_sessions; use crate::update; use crate::DaveOverlay; +/// Build a DaveUi from a session, wiring up all the common builder fields. +fn build_dave_ui<'a>( + session: &'a mut ChatSession, + model_config: &ModelConfig, + is_interrupt_pending: bool, + auto_steal_focus: bool, +) -> DaveUi<'a> { + let is_working = session.status() == AgentStatus::Working; + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + let is_remote = session.is_remote(); + + let mut ui_builder = DaveUi::new( + model_config.trial, + &session.chat, + &mut session.input, + &mut session.focus_requested, + session.ai_mode, + ) + .is_working(is_working) + .interrupt_pending(is_interrupt_pending) + .has_pending_permission(has_pending_permission) + .plan_mode_active(plan_mode_active) + .auto_steal_focus(auto_steal_focus) + .is_remote(is_remote); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting) + .git_status(&mut agentic.git_status); + } + + ui_builder +} + +/// Set tentative permission state on the active session's agentic data. +fn set_tentative_state(session_manager: &mut SessionManager, state: PermissionMessageState) { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = state; + } + session.focus_requested = true; + } +} + /// UI result from overlay rendering pub enum OverlayResult { /// No action taken @@ -213,36 +261,14 @@ pub fn scene_ui( ui.heading(&session.title); ui.separator(); - let is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let is_remote = session.is_remote(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, + let response = build_dave_ui( + session, + model_config, + is_interrupt_pending, + auto_steal_focus, ) .compact(true) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus) - .is_remote(is_remote); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - let response = ui_builder.ui(app_ctx, ui); + .ui(app_ctx, ui); if response.action.is_some() { dave_response = response; } @@ -339,35 +365,8 @@ pub fn desktop_ui( let chat_response = ui .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { if let Some(session) = session_manager.get_active_mut() { - let is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let is_remote = session.is_remote(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, - ) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus) - .is_remote(is_remote); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - ui_builder.ui(app_ctx, ui) + build_dave_ui(session, model_config, is_interrupt_pending, auto_steal_focus) + .ui(app_ctx, ui) } else { DaveResponse::default() } @@ -401,35 +400,10 @@ pub fn narrow_ui( .inner; (DaveResponse::default(), session_action) } else if let Some(session) = session_manager.get_active_mut() { - let is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - let is_remote = session.is_remote(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, - ) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus) - .is_remote(is_remote); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - (ui_builder.ui(app_ctx, ui), None) + let response = + build_dave_ui(session, model_config, is_interrupt_pending, auto_steal_focus) + .ui(app_ctx, ui); + (response, None) } else { (DaveResponse::default(), None) } @@ -519,21 +493,11 @@ pub fn handle_key_action( KeyActionResult::None } KeyAction::TentativeAccept => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); KeyActionResult::None } KeyAction::TentativeDeny => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); KeyActionResult::None } KeyAction::CancelTentative => { @@ -769,21 +733,11 @@ pub fn handle_ui_action( UiActionResult::Handled } DaveAction::TentativeAccept => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); UiActionResult::Handled } DaveAction::TentativeDeny => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); UiActionResult::Handled } DaveAction::QuestionResponse { diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -404,6 +404,32 @@ pub fn handle_question_response( // Agent Navigation // ============================================================================= +/// Switch to a session and optionally focus it in the scene. +/// +/// Handles the common pattern of: switch_to → scene.select → scene.focus_on → focus_requested. +/// Used by navigation, focus queue, and auto-steal-focus operations. +pub fn switch_and_focus_session( + session_manager: &mut SessionManager, + scene: &mut AgentScene, + show_scene: bool, + id: SessionId, +) { + session_manager.switch_to(id); + if show_scene { + scene.select(id); + if let Some(session) = session_manager.get(id) { + if let Some(agentic) = &session.agentic { + scene.focus_on(agentic.scene_position); + } + } + } + if let Some(session) = session_manager.get_mut(id) { + if !session.has_pending_permissions() { + session.focus_requested = true; + } + } +} + /// Switch to agent by index in the ordered list (0-indexed). pub fn switch_to_agent_by_index( session_manager: &mut SessionManager, @@ -413,23 +439,16 @@ pub fn switch_to_agent_by_index( ) { let ids = session_manager.session_ids(); if let Some(&id) = ids.get(index) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, id); } } -/// Cycle to the next agent. -pub fn cycle_next_agent( +/// Cycle agents using a direction function that computes the next index. +fn cycle_agent( session_manager: &mut SessionManager, scene: &mut AgentScene, show_scene: bool, + index_fn: impl FnOnce(usize, usize) -> usize, ) { let ids = session_manager.session_ids(); if ids.is_empty() { @@ -439,50 +458,32 @@ pub fn cycle_next_agent( .active_id() .and_then(|active| ids.iter().position(|&id| id == active)) .unwrap_or(0); - let next_idx = (current_idx + 1) % ids.len(); + let next_idx = index_fn(current_idx, ids.len()); if let Some(&id) = ids.get(next_idx) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, id); } } +/// Cycle to the next agent. +pub fn cycle_next_agent( + session_manager: &mut SessionManager, + scene: &mut AgentScene, + show_scene: bool, +) { + cycle_agent(session_manager, scene, show_scene, |idx, len| { + (idx + 1) % len + }); +} + /// Cycle to the previous agent. pub fn cycle_prev_agent( session_manager: &mut SessionManager, scene: &mut AgentScene, show_scene: bool, ) { - let ids = session_manager.session_ids(); - if ids.is_empty() { - return; - } - let current_idx = session_manager - .active_id() - .and_then(|active| ids.iter().position(|&id| id == active)) - .unwrap_or(0); - let prev_idx = if current_idx == 0 { - ids.len() - 1 - } else { - current_idx - 1 - }; - if let Some(&id) = ids.get(prev_idx) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } - } + cycle_agent(session_manager, scene, show_scene, |idx, len| { + if idx == 0 { len - 1 } else { idx - 1 } + }); } // ============================================================================= @@ -497,20 +498,7 @@ pub fn focus_queue_next( show_scene: bool, ) { if let Some(session_id) = focus_queue.next() { - session_manager.switch_to(session_id); - if show_scene { - scene.select(session_id); - if let Some(session) = session_manager.get(session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } - if let Some(session) = session_manager.get_mut(session_id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, session_id); } } @@ -522,20 +510,7 @@ pub fn focus_queue_prev( show_scene: bool, ) { if let Some(session_id) = focus_queue.prev() { - session_manager.switch_to(session_id); - if show_scene { - scene.select(session_id); - if let Some(session) = session_manager.get(session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } - if let Some(session) = session_manager.get_mut(session_id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, session_id); } } @@ -566,15 +541,7 @@ pub fn toggle_auto_steal( } else { // Disabling: switch back to home session if set if let Some(home_id) = home_session.take() { - session_manager.switch_to(home_id); - if show_scene { - scene.select(home_id); - if let Some(session) = session_manager.get(home_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, home_id); tracing::debug!("Auto-steal focus disabled, returned to home session"); } } @@ -622,15 +589,7 @@ pub fn process_auto_steal_focus( if let Some(idx) = focus_queue.first_needs_input_index() { focus_queue.set_cursor(idx); if let Some(entry) = focus_queue.current() { - session_manager.switch_to(entry.session_id); - if show_scene { - scene.select(entry.session_id); - if let Some(session) = session_manager.get(entry.session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, entry.session_id); tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id); return true; } @@ -653,15 +612,7 @@ pub fn process_auto_steal_focus( if let Some(idx) = focus_queue.first_done_index() { focus_queue.set_cursor(idx); if let Some(entry) = focus_queue.current() { - session_manager.switch_to(entry.session_id); - if show_scene { - scene.select(entry.session_id); - if let Some(session) = session_manager.get(entry.session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, entry.session_id); tracing::debug!("Auto-steal: switched to Done session {:?}", entry.session_id); return true; } @@ -669,15 +620,7 @@ pub fn process_auto_steal_focus( } } else if let Some(home_id) = home_session.take() { // No more NeedsInput or Done items - return to saved session - session_manager.switch_to(home_id); - if show_scene { - scene.select(home_id); - if let Some(session) = session_manager.get(home_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, home_id); tracing::debug!("Auto-steal: returned to home session {:?}", home_id); }