diff --git a/docs/tui.md b/docs/tui.md index 9c6c69c..e6d5060 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -164,6 +164,7 @@ The chat panel asks AI against either all searchable memories or one memory from - Existing saved chat history is not migrated; the TUI starts from the new thread store file - Use `↑` `↓` in the list and `Enter` to open details - Move focus to the detail pane and press `Enter` on the selected `Name` row to edit the selected memory name and description +- For public memories with `anonymous` reader or writer access, move focus to the detail pane and press `Enter` on `Open public memory in browser` to open `https://memory.kinic.xyz/m/` - Move to `+ Add Existing Memory Canister` at the end of the list and press `Enter` to register an existing memory manually - In the modal, enter an existing memory canister id and submit it to validate access via `get_users()` - For manually added memories, move focus to the detail pane and use `Tab` / `Shift+Tab` to jump between actions, including `Remove from list` diff --git a/rust/tui/provider/mod.rs b/rust/tui/provider/mod.rs index 9b18a5a..a4fceeb 100644 --- a/rust/tui/provider/mod.rs +++ b/rust/tui/provider/mod.rs @@ -51,6 +51,8 @@ use tui_kit_runtime::{ }, }; +const PUBLIC_MEMORY_BASE_URL: &str = "https://memory.kinic.xyz/m"; + #[derive(Debug, Clone)] pub struct TuiConfig { pub auth: TuiAuth, @@ -943,6 +945,7 @@ enum MemoryContentSelection<'a> { RenameMemory, User(&'a bridge::MemoryUser), AddUser, + OpenPublicMemory, RemoveManualMemory, } @@ -1762,6 +1765,9 @@ impl KinicProvider { selections.extend(users.iter().map(MemoryContentSelection::User)); } selections.push(MemoryContentSelection::AddUser); + if memory_has_anonymous_read_access(summary.users.as_ref()) { + selections.push(MemoryContentSelection::OpenPublicMemory); + } if self.active_memory_is_manual() { selections.push(MemoryContentSelection::RemoveManualMemory); } @@ -1822,17 +1828,30 @@ impl KinicProvider { content .sections .retain(|section| section.heading != "Actions"); + let mut action_lines = Vec::new(); + if memory_has_anonymous_read_access(users) { + action_lines.push(marker_line( + matches!( + current_selection, + Some(MemoryContentSelection::OpenPublicMemory) + ), + "Open public memory in browser", + )); + } if self.active_memory_is_manual() { + action_lines.push(marker_line( + matches!( + current_selection, + Some(MemoryContentSelection::RemoveManualMemory) + ), + "Remove from list", + )); + } + if !action_lines.is_empty() { content.sections.push(tui_kit_model::UiSection { heading: "Actions".to_string(), rows: Vec::new(), - body_lines: vec![marker_line( - matches!( - current_selection, - Some(MemoryContentSelection::RemoveManualMemory) - ), - "Remove from list", - )], + body_lines: action_lines, }); } } @@ -4699,6 +4718,11 @@ impl DataProvider for KinicProvider { Some(MemoryContentSelection::AddUser) | None => { effects.push(CoreEffect::OpenAccessAdd { memory_id }); } + Some(MemoryContentSelection::OpenPublicMemory) => { + effects.push(CoreEffect::OpenExternal(format!( + "{PUBLIC_MEMORY_BASE_URL}/{memory_id}" + ))); + } Some(MemoryContentSelection::RemoveManualMemory) => { effects.push(CoreEffect::OpenRemoveMemory); } @@ -5230,6 +5254,15 @@ fn render_access_lines( lines } +fn memory_has_anonymous_read_access(users: Option<&Vec>) -> bool { + users.is_some_and(|users| { + users.iter().any(|user| { + matches!(user.principal_id.as_str(), "anonymous" | "2vxsx-fae") + && matches!(user.role.as_str(), "reader" | "writer") + }) + }) +} + fn short_error(message: &str) -> String { message.lines().next().unwrap_or(message).trim().to_string() } diff --git a/rust/tui/provider/tests/snapshot.rs b/rust/tui/provider/tests/snapshot.rs index 35b8146..239d252 100644 --- a/rust/tui/provider/tests/snapshot.rs +++ b/rust/tui/provider/tests/snapshot.rs @@ -324,6 +324,249 @@ fn build_snapshot_shows_remove_action_only_for_manual_memory() { ); } +#[test] +fn build_snapshot_shows_public_action_for_anonymous_reader() { + let mut provider = KinicProvider::new(live_config()); + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "reader".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 3, + ..CoreState::default() + }); + + let content = snapshot.selected_content.expect("selected content"); + let actions = content + .sections + .iter() + .find(|section| section.heading == "Actions") + .expect("actions section"); + assert_eq!( + actions.body_lines, + vec!["> Open public memory in browser".to_string()] + ); +} + +#[test] +fn build_snapshot_shows_public_action_for_anonymous_principal_reader() { + let mut provider = KinicProvider::new(live_config()); + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "2vxsx-fae".to_string(), + role: "reader".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 3, + ..CoreState::default() + }); + + let content = snapshot.selected_content.expect("selected content"); + let actions = content + .sections + .iter() + .find(|section| section.heading == "Actions") + .expect("actions section"); + assert_eq!( + actions.body_lines, + vec!["> Open public memory in browser".to_string()] + ); +} + +#[test] +fn build_snapshot_shows_public_action_for_anonymous_writer() { + let mut provider = KinicProvider::new(live_config()); + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "writer".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 3, + ..CoreState::default() + }); + + let content = snapshot.selected_content.expect("selected content"); + let actions = content + .sections + .iter() + .find(|section| section.heading == "Actions") + .expect("actions section"); + assert_eq!( + actions.body_lines, + vec!["> Open public memory in browser".to_string()] + ); +} + +#[test] +fn build_snapshot_hides_public_action_without_anonymous_read_access() { + for users in [ + None, + Some(vec![bridge::MemoryUser { + principal_id: "2vxsx-fae".to_string(), + role: "admin".to_string(), + }]), + Some(vec![bridge::MemoryUser { + principal_id: "user-1".to_string(), + role: "reader".to_string(), + }]), + ] { + let mut provider = KinicProvider::new(live_config()); + provider.memory_summaries = vec![MemorySummary { + users, + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + ..CoreState::default() + }); + + let content = snapshot.selected_content.expect("selected content"); + assert!( + content + .sections + .iter() + .all(|section| section.heading != "Actions") + ); + } +} + +#[test] +fn build_snapshot_shows_public_and_remove_actions_for_public_manual_memory() { + let mut provider = KinicProvider::new(live_config()); + provider.user_preferences.manual_memory_ids = vec!["aaaaa-aa".to_string()]; + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "reader".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let snapshot = provider.build_snapshot(&CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 4, + ..CoreState::default() + }); + + let content = snapshot.selected_content.expect("selected content"); + let actions = content + .sections + .iter() + .find(|section| section.heading == "Actions") + .expect("actions section"); + assert_eq!( + actions.body_lines, + vec![ + " Open public memory in browser".to_string(), + "> Remove from list".to_string(), + ] + ); +} + +#[test] +fn memory_content_open_selected_opens_public_memory_url() { + let mut provider = KinicProvider::new(live_config()); + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "reader".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let output = provider + .handle_action( + &CoreAction::MemoryContentOpenSelected, + &CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 3, + ..CoreState::default() + }, + ) + .expect("open selected output"); + + assert!(output.effects.iter().any(|effect| matches!( + effect, + CoreEffect::OpenExternal(url) if url == "https://memory.kinic.xyz/m/aaaaa-aa" + ))); +} + +#[test] +fn memory_content_open_selected_opens_public_and_remove_actions_for_public_manual_memory() { + let mut provider = KinicProvider::new(live_config()); + provider.user_preferences.manual_memory_ids = vec!["aaaaa-aa".to_string()]; + provider.memory_summaries = vec![MemorySummary { + users: Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "reader".to_string(), + }]), + ..running_memory_summary("aaaaa-aa", "first") + }]; + provider.refresh_memory_records_from_summaries(); + set_memory_selection(&mut provider, "aaaaa-aa"); + + let public_output = provider + .handle_action( + &CoreAction::MemoryContentOpenSelected, + &CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 3, + ..CoreState::default() + }, + ) + .expect("open selected output"); + + assert!(public_output.effects.iter().any(|effect| matches!( + effect, + CoreEffect::OpenExternal(url) if url == "https://memory.kinic.xyz/m/aaaaa-aa" + ))); + + let remove_output = provider + .handle_action( + &CoreAction::MemoryContentOpenSelected, + &CoreState { + current_tab_id: KINIC_MEMORIES_TAB_ID.to_string(), + memory_content_action_index: 4, + ..CoreState::default() + }, + ) + .expect("open selected output"); + + assert!( + remove_output + .effects + .iter() + .any(|effect| matches!(effect, CoreEffect::OpenRemoveMemory)) + ); +} + #[test] fn memory_content_open_selected_opens_remove_modal_for_manual_memory_action() { let mut provider = KinicProvider::new(live_config());