From 4af8c27341152ecb440d39cf4ce75350961b383f Mon Sep 17 00:00:00 2001 From: hude Date: Mon, 4 May 2026 10:41:56 +0900 Subject: [PATCH 1/2] Add public memory link to TUI details --- docs/tui.md | 1 + rust/tui/provider/mod.rs | 44 ++++++++-- rust/tui/provider/tests/snapshot.rs | 126 ++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 9c6c69c..ad316ee 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 access, move focus to the detail pane and press `Enter` on `Open public memory` 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..b844754 100644 --- a/rust/tui/provider/mod.rs +++ b/rust/tui/provider/mod.rs @@ -943,6 +943,7 @@ enum MemoryContentSelection<'a> { RenameMemory, User(&'a bridge::MemoryUser), AddUser, + OpenPublicMemory, RemoveManualMemory, } @@ -1762,6 +1763,9 @@ impl KinicProvider { selections.extend(users.iter().map(MemoryContentSelection::User)); } selections.push(MemoryContentSelection::AddUser); + if memory_has_anonymous_reader(summary.users.as_ref()) { + selections.push(MemoryContentSelection::OpenPublicMemory); + } if self.active_memory_is_manual() { selections.push(MemoryContentSelection::RemoveManualMemory); } @@ -1822,17 +1826,30 @@ impl KinicProvider { content .sections .retain(|section| section.heading != "Actions"); + let mut action_lines = Vec::new(); + if memory_has_anonymous_reader(users) { + action_lines.push(marker_line( + matches!( + current_selection, + Some(MemoryContentSelection::OpenPublicMemory) + ), + "Open public memory", + )); + } 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 +4716,11 @@ impl DataProvider for KinicProvider { Some(MemoryContentSelection::AddUser) | None => { effects.push(CoreEffect::OpenAccessAdd { memory_id }); } + Some(MemoryContentSelection::OpenPublicMemory) => { + effects.push(CoreEffect::OpenExternal(format!( + "https://memory.kinic.xyz/m/{memory_id}" + ))); + } Some(MemoryContentSelection::RemoveManualMemory) => { effects.push(CoreEffect::OpenRemoveMemory); } @@ -5230,6 +5252,14 @@ fn render_access_lines( lines } +fn memory_has_anonymous_reader(users: Option<&Vec>) -> bool { + users.is_some_and(|users| { + users.iter().any(|user| { + matches!(user.principal_id.as_str(), "anonymous" | "2vxsx-fae") && user.role == "reader" + }) + }) +} + 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..38a1685 100644 --- a/rust/tui/provider/tests/snapshot.rs +++ b/rust/tui/provider/tests/snapshot.rs @@ -324,6 +324,132 @@ 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".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".to_string()]); +} + +#[test] +fn build_snapshot_hides_public_action_without_anonymous_reader() { + for users in [ + None, + Some(vec![bridge::MemoryUser { + principal_id: "anonymous".to_string(), + role: "writer".to_string(), + }]), + 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 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_remove_modal_for_manual_memory_action() { let mut provider = KinicProvider::new(live_config()); From 18c3e0c26a0879be3e78a8df9fa778b1db4ec442 Mon Sep 17 00:00:00 2001 From: hude Date: Tue, 5 May 2026 17:05:20 +0900 Subject: [PATCH 2/2] Allow anonymous writer public memory links in TUI --- docs/tui.md | 2 +- rust/tui/provider/mod.rs | 15 ++-- rust/tui/provider/tests/snapshot.rs | 129 ++++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 13 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index ad316ee..e6d5060 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -164,7 +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 access, move focus to the detail pane and press `Enter` on `Open public memory` to open `https://memory.kinic.xyz/m/` +- 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 b844754..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, @@ -1763,7 +1765,7 @@ impl KinicProvider { selections.extend(users.iter().map(MemoryContentSelection::User)); } selections.push(MemoryContentSelection::AddUser); - if memory_has_anonymous_reader(summary.users.as_ref()) { + if memory_has_anonymous_read_access(summary.users.as_ref()) { selections.push(MemoryContentSelection::OpenPublicMemory); } if self.active_memory_is_manual() { @@ -1827,13 +1829,13 @@ impl KinicProvider { .sections .retain(|section| section.heading != "Actions"); let mut action_lines = Vec::new(); - if memory_has_anonymous_reader(users) { + if memory_has_anonymous_read_access(users) { action_lines.push(marker_line( matches!( current_selection, Some(MemoryContentSelection::OpenPublicMemory) ), - "Open public memory", + "Open public memory in browser", )); } if self.active_memory_is_manual() { @@ -4718,7 +4720,7 @@ impl DataProvider for KinicProvider { } Some(MemoryContentSelection::OpenPublicMemory) => { effects.push(CoreEffect::OpenExternal(format!( - "https://memory.kinic.xyz/m/{memory_id}" + "{PUBLIC_MEMORY_BASE_URL}/{memory_id}" ))); } Some(MemoryContentSelection::RemoveManualMemory) => { @@ -5252,10 +5254,11 @@ fn render_access_lines( lines } -fn memory_has_anonymous_reader(users: Option<&Vec>) -> bool { +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") && user.role == "reader" + matches!(user.principal_id.as_str(), "anonymous" | "2vxsx-fae") + && matches!(user.role.as_str(), "reader" | "writer") }) }) } diff --git a/rust/tui/provider/tests/snapshot.rs b/rust/tui/provider/tests/snapshot.rs index 38a1685..239d252 100644 --- a/rust/tui/provider/tests/snapshot.rs +++ b/rust/tui/provider/tests/snapshot.rs @@ -349,7 +349,10 @@ fn build_snapshot_shows_public_action_for_anonymous_reader() { .iter() .find(|section| section.heading == "Actions") .expect("actions section"); - assert_eq!(actions.body_lines, vec!["> Open public memory".to_string()]); + assert_eq!( + actions.body_lines, + vec!["> Open public memory in browser".to_string()] + ); } #[test] @@ -377,17 +380,47 @@ fn build_snapshot_shows_public_action_for_anonymous_principal_reader() { .iter() .find(|section| section.heading == "Actions") .expect("actions section"); - assert_eq!(actions.body_lines, vec!["> Open public memory".to_string()]); + assert_eq!( + actions.body_lines, + vec!["> Open public memory in browser".to_string()] + ); } #[test] -fn build_snapshot_hides_public_action_without_anonymous_reader() { - for users in [ - None, - Some(vec![bridge::MemoryUser { +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(), @@ -420,6 +453,41 @@ fn build_snapshot_hides_public_action_without_anonymous_reader() { } } +#[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()); @@ -450,6 +518,55 @@ fn memory_content_open_selected_opens_public_memory_url() { ))); } +#[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());