use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
static ALL_DIRECTORIES: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
pub static SCOPE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
use bcode_plugin_sdk::path::display_from_current_dir;
use bcode_session_models::{SessionId, SessionSummary};
use bcode_session_view::navigation::{SessionNavigationKey, SessionTreeRow, project_session_tree};
use bmux_tui::prelude::Line as ListItem;
use bmux_tui::prelude::{Line, Span, Style};
use bmux_tui::style::Modifier;
use bmux_tui_components::selectable_list::SelectableListState as ListState;
use bmux_tui_components::text_input::TextInputState;
use super::filtered_list::FilteredListState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionPickerMode {
Filter,
Rename,
DeleteConfirm,
TranscriptSearch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogScope {
Directory,
AllDirectories,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogSelection {
FollowTop,
PreserveIdentity,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionPickerApp {
sessions: Vec<SessionSummary>,
catalog_scope: CatalogScope,
catalog_cursor: Option<bcode_session_models::SessionCatalogCursor>,
catalog_next_cursor: Option<bcode_session_models::SessionCatalogCursor>,
child_loaded: Vec<SessionNavigationKey>,
expanded: BTreeSet<SessionNavigationKey>,
tree_rows: Vec<SessionTreeRow>,
child_cursors: std::collections::BTreeMap<SessionId, Option<SessionId>>,
filter: TextInputState,
rename: TextInputState,
list: FilteredListState,
catalog_selection: CatalogSelection,
list_area: Option<bmux_tui::geometry::Rect>,
status: String,
empty_message: String,
last_import: Option<(SessionSummary, Vec<bcode_ipc::SessionImportWarning>)>,
search_results: Vec<bcode_session_search::HydratedSessionSearchHit>,
search_query_complete: bool,
search_coverage_complete: bool,
search_provider_reports: usize,
search_failures: usize,
search_controls: super::session_search::SessionSearchControls,
search_cursor: Option<bcode_session_search::SearchCursor>,
search_preview: String,
migration_confirmation_armed: bool,
bulk_migration_operation_id: Option<String>,
search_backfill_operation_id: Option<String>,
mode: SessionPickerMode,
}
impl SessionPickerApp {
#[must_use]
pub fn new(sessions: Vec<SessionSummary>) -> Self {
let list = FilteredListState::new(sessions.len());
Self {
sessions,
filter: super::text_input_flow::empty_state(),
rename: super::text_input_flow::empty_state(),
list,
catalog_selection: CatalogSelection::FollowTop,
status: "Select a session or press Ctrl-N to create one".to_owned(),
empty_message: "No matching sessions. Press Ctrl-N to create a new session.".to_owned(),
last_import: None,
list_area: None,
catalog_cursor: None,
catalog_next_cursor: None,
catalog_scope: CatalogScope::Directory,
search_results: Vec::new(),
search_query_complete: true,
search_coverage_complete: true,
search_provider_reports: 0,
search_failures: 0,
search_controls: super::session_search::SessionSearchControls::default(),
search_cursor: None,
search_preview: String::new(),
migration_confirmation_armed: false,
bulk_migration_operation_id: None,
search_backfill_operation_id: None,
child_loaded: Vec::new(),
expanded: BTreeSet::new(),
tree_rows: Vec::new(),
child_cursors: std::collections::BTreeMap::new(),
mode: SessionPickerMode::Filter,
}
}
#[must_use]
pub fn remembered(sessions: Vec<SessionSummary>) -> Self {
let mut app = Self::new(sessions);
app.catalog_scope = if ALL_DIRECTORIES.load(Ordering::Relaxed) {
CatalogScope::AllDirectories
} else {
CatalogScope::Directory
};
app.refresh_filter();
app
}
#[must_use]
pub const fn all_directories(&self) -> bool {
matches!(self.catalog_scope, CatalogScope::AllDirectories)
}
pub fn set_all_directories(&mut self, all: bool) -> bool {
if self.all_directories() == all || self.mode != SessionPickerMode::Filter {
return false;
}
self.catalog_scope = if all {
CatalogScope::AllDirectories
} else {
CatalogScope::Directory
};
ALL_DIRECTORIES.store(all, Ordering::Relaxed);
self.restart_catalog();
self.sessions.clear();
self.child_loaded.clear();
self.expanded.clear();
self.tree_rows.clear();
self.child_cursors.clear();
self.refresh_filter();
self.set_loading_status("Loading sessions…".to_owned());
true
}
#[must_use]
pub const fn has_catalog_continuation(&self) -> bool {
self.catalog_next_cursor.is_some()
}
#[must_use]
pub const fn catalog_cursor(&self) -> Option<&bcode_session_models::SessionCatalogCursor> {
self.catalog_cursor.as_ref()
}
pub fn set_catalog_next_cursor(
&mut self,
cursor: Option<bcode_session_models::SessionCatalogCursor>,
) {
self.catalog_next_cursor = cursor;
}
pub fn next_catalog_page(&mut self) -> bool {
let Some(cursor) = self.catalog_next_cursor.take() else {
return false;
};
self.catalog_cursor = Some(cursor);
self.replace_sessions(Vec::new());
self.set_loading_status("Loading next page…".to_owned());
true
}
pub fn restart_catalog(&mut self) {
self.catalog_cursor = None;
self.catalog_next_cursor = None;
}
#[must_use]
pub const fn mode(&self) -> SessionPickerMode {
self.mode
}
#[must_use]
pub const fn filter(&self) -> &TextInputState {
&self.filter
}
pub const fn filter_mut(&mut self) -> &mut TextInputState {
&mut self.filter
}
#[must_use]
pub const fn rename(&self) -> &TextInputState {
&self.rename
}
pub const fn rename_mut(&mut self) -> &mut TextInputState {
&mut self.rename
}
pub const fn active_input_mut(&mut self) -> &mut TextInputState {
match self.mode {
SessionPickerMode::Filter
| SessionPickerMode::DeleteConfirm
| SessionPickerMode::TranscriptSearch => &mut self.filter,
SessionPickerMode::Rename => &mut self.rename,
}
}
pub const fn set_list_area(&mut self, area: Option<bmux_tui::geometry::Rect>) {
self.list_area = area;
}
pub fn select_mouse(&mut self, mouse: bmux_tui::event::MouseEvent) {
if let Some(area) = self.list_area
&& let Some(row) = super::picker_mouse::command_palette_row_in_area(mouse, area)
{
let _ = self.select_visible(self.list.offset().saturating_add(row));
}
}
pub fn list_render_state(&mut self, viewport_height: u16) -> &mut ListState {
self.list.render_state(viewport_height)
}
#[must_use]
pub fn status(&self) -> &str {
&self.status
}
pub fn set_status(&mut self, status: String) {
self.status = status;
}
pub fn set_loading_status(&mut self, status: String) {
self.status.clone_from(&status);
self.empty_message = status;
}
pub fn set_idle_empty_message(&mut self) {
"No matching sessions. Press Ctrl-N to create a new session."
.clone_into(&mut self.empty_message);
}
pub fn set_last_import(
&mut self,
import: Option<(SessionSummary, Vec<bcode_ipc::SessionImportWarning>)>,
) {
self.last_import = import;
}
#[must_use]
pub const fn last_import(
&self,
) -> Option<&(SessionSummary, Vec<bcode_ipc::SessionImportWarning>)> {
self.last_import.as_ref()
}
pub fn replace_sessions(&mut self, sessions: Vec<SessionSummary>) {
let preserve_selection = self.catalog_selection == CatalogSelection::PreserveIdentity
|| matches!(
self.mode,
SessionPickerMode::Rename | SessionPickerMode::DeleteConfirm
);
let selected = self
.list
.selected_source_index()
.and_then(|index| self.sessions.get(index))
.map(|session| {
(
session.id,
session
.location
.as_ref()
.map(|location| location.location_id.clone()),
)
});
self.sessions = sessions;
if self.mode == SessionPickerMode::TranscriptSearch {
return;
}
self.refresh_filter();
if !preserve_selection {
self.list.select_visible(0);
return;
}
let selected_row = selected.and_then(|selected| {
self.list.indices().iter().position(|index| {
let session = &self.sessions[*index];
session.id == selected.0
&& session
.location
.as_ref()
.map(|location| location.location_id.as_str())
== selected.1.as_deref()
})
});
if let Some(row) = selected_row {
self.list.select_visible(row);
} else if matches!(
self.mode,
SessionPickerMode::Rename | SessionPickerMode::DeleteConfirm
) {
self.mode = SessionPickerMode::Filter;
self.rename = super::text_input_flow::empty_state();
"Selected session is no longer available; action canceled".clone_into(&mut self.status);
}
}
pub fn set_catalog_status(&mut self, status: &bcode_session_models::SessionCatalogStatus) {
use bcode_session_models::SessionCatalogStatus;
if self.mode != SessionPickerMode::Filter {
return;
}
let count = self.sessions.len();
match status {
SessionCatalogStatus::NotStarted | SessionCatalogStatus::Loading => {
self.set_loading_status(format!("{count} sessions; discovering more…"));
}
SessionCatalogStatus::Loaded => {
self.set_status(format!("{count} sessions"));
self.set_idle_empty_message();
}
SessionCatalogStatus::Degraded(message) | SessionCatalogStatus::Failed(message) => {
self.set_loading_status(format!(
"{count} sessions; catalog unavailable: {message}"
));
}
}
}
pub fn start_transcript_search(&mut self) {
self.search_results.clear();
self.search_cursor = None;
self.search_preview.clear();
self.mode = SessionPickerMode::TranscriptSearch;
self.list = FilteredListState::new(0);
"Type a transcript query, then press Enter".clone_into(&mut self.status);
"No transcript search results".clone_into(&mut self.empty_message);
}
pub fn set_search_results(
&mut self,
response: &bcode_session_search::FederatedSessionSearchResponse,
hydrated: Vec<bcode_session_search::HydratedSessionSearchHit>,
) {
self.search_results = hydrated;
self.search_query_complete = response.query_complete;
self.search_coverage_complete = response.coverage_complete;
self.search_provider_reports = response.providers.len();
self.search_failures = response.failures.len();
self.search_cursor = response
.providers
.iter()
.find_map(|provider| provider.next_cursor.clone());
self.list = FilteredListState::new(self.search_results.len());
self.mode = SessionPickerMode::TranscriptSearch;
self.refresh_search_preview();
let query = if self.search_query_complete {
"query complete"
} else {
"query incomplete"
};
let coverage = if self.search_coverage_complete {
"searchable coverage complete"
} else {
"searchable coverage incomplete"
};
let failures = if self.search_failures == 0 {
"no provider failures".to_owned()
} else {
format!("{} provider failures", self.search_failures)
};
self.status = format!(
"{} results from {} providers; {query}; {coverage}; {failures}; {}. Press ? for details",
self.search_results.len(),
self.search_provider_reports,
self.search_controls.depth.explanation()
);
}
#[must_use]
pub fn selected_search_result(
&self,
) -> Option<&bcode_session_search::HydratedSessionSearchHit> {
let index = self.list.selected_source_index()?;
self.search_results.get(index)
}
pub fn cycle_search_match_mode(&mut self) {
self.search_controls.cycle_match_mode();
self.status = format!("Match mode: {:?}", self.search_controls.match_mode);
}
pub fn toggle_search_depth(&mut self) {
self.search_controls.toggle_depth();
self.search_controls
.depth
.explanation()
.clone_into(&mut self.status);
}
pub fn cycle_search_sort(&mut self) {
self.search_controls.cycle_sort();
self.status = format!("Sort: {:?}", self.search_controls.sort);
}
pub fn search_request(
&self,
query: &str,
continue_page: bool,
) -> Result<
(
bcode_session_search::SessionSearchRequest,
bcode_session_search::SessionSearchPlanPolicy,
),
String,
> {
let parsed =
super::session_search::parse_search_input(query, self.search_controls.clone())?;
let cursor = continue_page.then(|| self.search_cursor.clone()).flatten();
Ok((
parsed.controls.request(parsed.query, cursor),
parsed.controls.depth.policy(),
))
}
#[must_use]
pub const fn has_next_search_page(&self) -> bool {
self.search_cursor.is_some()
}
#[must_use]
pub fn search_preview(&self) -> &str {
&self.search_preview
}
fn refresh_search_preview(&mut self) {
self.search_preview = self
.selected_search_result()
.and_then(|result| result.hit.preview.as_deref())
.unwrap_or("No canonical preview available")
.chars()
.take(512)
.collect();
}
pub const fn confirm_canonical_migration(&mut self) -> bool {
if self.migration_confirmation_armed {
self.migration_confirmation_armed = false;
true
} else {
self.migration_confirmation_armed = true;
false
}
}
pub fn set_bulk_migration_operation_id(&mut self, operation_id: String) {
self.bulk_migration_operation_id = Some(operation_id);
}
pub const fn take_bulk_migration_operation_id(&mut self) -> Option<String> {
self.bulk_migration_operation_id.take()
}
pub fn set_search_backfill_operation_id(&mut self, operation_id: String) {
self.search_backfill_operation_id = Some(operation_id);
}
pub const fn take_search_backfill_operation_id(&mut self) -> Option<String> {
self.search_backfill_operation_id.take()
}
pub fn close_search_results(&mut self) {
self.search_results.clear();
self.search_cursor = None;
self.search_preview.clear();
self.migration_confirmation_armed = false;
self.mode = SessionPickerMode::Filter;
self.list = FilteredListState::new(self.sessions.len());
self.refresh_filter();
"Transcript search closed".clone_into(&mut self.status);
}
#[must_use]
pub fn list_items(&self, muted: Style) -> Vec<ListItem> {
if self.mode == SessionPickerMode::TranscriptSearch {
if self.search_results.is_empty() {
return vec![empty_item("No transcript matches", muted)];
}
return self
.search_results
.iter()
.map(|result| {
let title = self
.sessions
.iter()
.find(|session| session.id == result.hit.locator.session_id)
.map(SessionSummary::display_title);
search_result_item(result, title, muted)
})
.collect();
}
if self.list.indices().is_empty() {
return vec![empty_item(&self.empty_message, muted)];
}
self.list
.indices()
.iter()
.map(|index| {
let mut item = session_item(&self.sessions[*index], muted);
if let Some(row) = self.tree_rows.iter().find(|row| row.source_index == *index) {
let marker = if self
.expanded
.contains(&SessionNavigationKey::from_summary(&self.sessions[*index]))
{
"▾"
} else {
"▸"
};
let context = if row.detached { "? " } else { "" };
let depth = if row.depth > 4 {
format!("[{}] ", row.depth)
} else {
" ".repeat(row.depth)
};
item.spans
.insert(0, Span::styled(format!("{depth}{marker} {context}"), muted));
}
item
})
.collect()
}
#[must_use]
pub fn selected_session_id(&self) -> Option<SessionId> {
let index = self.list.selected_source_index()?;
Some(self.sessions[index].id)
}
#[must_use]
pub fn selected_import(&self) -> Option<&bcode_session_models::SessionImportSummary> {
let index = self.list.selected_source_index()?;
self.sessions[index].import.as_ref()
}
#[must_use]
pub fn selected_session_name(&self) -> Option<&str> {
let index = self.list.selected_source_index()?;
self.sessions[index].name.as_deref()
}
pub fn select_visible(&mut self, row: usize) -> bool {
let selected = self.list.select_visible(row);
if selected && self.mode != SessionPickerMode::TranscriptSearch {
self.catalog_selection = CatalogSelection::PreserveIdentity;
}
if selected && self.mode == SessionPickerMode::TranscriptSearch {
self.refresh_search_preview();
}
selected
}
pub fn start_rename(&mut self) -> bool {
let Some(name) = self.selected_session_name() else {
"No session selected to rename".clone_into(&mut self.status);
return false;
};
self.rename = super::text_input_flow::state_with_text(name, true);
self.mode = SessionPickerMode::Rename;
"Confirm saves the new name; Cancel returns to the session list"
.clone_into(&mut self.status);
true
}
pub fn cancel_rename(&mut self) {
self.mode = SessionPickerMode::Filter;
"Rename canceled".clone_into(&mut self.status);
}
pub fn start_delete_confirmation(&mut self) -> bool {
if self.selected_session_id().is_none() {
"No session selected to delete".clone_into(&mut self.status);
return false;
}
self.mode = SessionPickerMode::DeleteConfirm;
"Delete selected session? y/N".clone_into(&mut self.status);
true
}
pub fn cancel_delete(&mut self) {
self.mode = SessionPickerMode::Filter;
"Delete canceled".clone_into(&mut self.status);
}
pub fn finish_mutation(&mut self, status: String) {
self.mode = SessionPickerMode::Filter;
self.status = status;
}
pub fn toggle_selected_branch(&mut self) -> Option<SessionId> {
let index = self.list.selected_source_index()?;
let key = SessionNavigationKey::from_summary(&self.sessions[index]);
let opening = if self.expanded.remove(&key) {
false
} else {
self.expanded.insert(key);
true
};
let id = self.sessions[index].id;
self.refresh_filter();
opening.then_some(id)
}
#[must_use]
pub fn next_child_page(&self) -> Option<(SessionId, SessionId)> {
let parent_id = self.selected_session_id()?;
self.child_cursors
.get(&parent_id)
.copied()
.flatten()
.map(|after| (parent_id, after))
}
pub fn select_parent(&mut self) {
let Some(index) = self.list.selected_source_index() else {
return;
};
let Some(parent) = SessionNavigationKey::parent(&self.sessions[index]) else {
return;
};
if let Some(row) =
self.list.indices().iter().position(|index| {
SessionNavigationKey::from_summary(&self.sessions[*index]) == parent
})
{
self.select_visible(row);
} else {
self.set_status("Parent not loaded in this location".to_owned());
}
}
pub fn apply_children(
&mut self,
parent_id: SessionId,
page: bcode_session_models::SessionChildPage,
) {
self.child_cursors.insert(parent_id, page.next_after);
let selected = self.selected_session_id();
for child in page.children {
let key = SessionNavigationKey::from_summary(&child);
self.child_loaded.retain(|loaded| loaded != &key);
self.child_loaded.push(key.clone());
if let Some(index) = self
.sessions
.iter()
.position(|summary| SessionNavigationKey::from_summary(summary) == key)
{
self.sessions[index] = child;
} else {
self.sessions.push(child);
}
}
while self.child_loaded.len() > 256 {
let evicted = self.child_loaded.remove(0);
if Some(evicted.session_id) == selected {
self.child_loaded.push(evicted);
continue;
}
if let Some(index) = self
.sessions
.iter()
.position(|summary| SessionNavigationKey::from_summary(summary) == evicted)
{
let summary = self.sessions.remove(index);
if let Some(parent) = summary.parent_id {
self.child_cursors.remove(&parent);
}
}
self.expanded.remove(&evicted);
self.child_cursors.remove(&evicted.session_id);
}
self.refresh_filter();
if let Some(row) = self
.list
.indices()
.iter()
.position(|index| Some(self.sessions[*index].id) == selected)
{
self.list.select_visible(row);
}
let coverage = if !page.complete {
"degraded/incomplete"
} else if page.next_after.is_some() {
"partial; Alt-N loads more"
} else {
"complete"
};
self.set_status(format!(
"Children of {parent_id}: {coverage} {}",
page.issues.join("; ")
));
}
pub fn refresh_filter(&mut self) {
if self.all_directories() {
self.tree_rows.clear();
self.list
.replace_indices((0..self.sessions.len()).collect());
return;
}
let query = self.filter.buffer().text().trim().to_ascii_lowercase();
let matches = (!self.all_directories() && !query.is_empty()).then(|| {
self.sessions
.iter()
.enumerate()
.filter_map(|(index, session)| session_matches(session, &query).then_some(index))
.collect::<BTreeSet<_>>()
});
let tree = project_session_tree(
&self.sessions,
&self.expanded,
matches.as_ref(),
self.sessions.len(),
);
let filtered_indices = tree.rows.iter().map(|row| row.source_index).collect();
self.tree_rows = tree.rows;
self.list.replace_indices(filtered_indices);
}
pub fn select_next(&mut self) {
if self.mode != SessionPickerMode::TranscriptSearch && !self.list.indices().is_empty() {
self.catalog_selection = CatalogSelection::PreserveIdentity;
}
self.list.select_next();
if self.mode == SessionPickerMode::TranscriptSearch {
self.refresh_search_preview();
}
}
pub fn select_previous(&mut self) {
if self.mode != SessionPickerMode::TranscriptSearch && !self.list.indices().is_empty() {
self.catalog_selection = CatalogSelection::PreserveIdentity;
}
self.list.select_previous();
if self.mode == SessionPickerMode::TranscriptSearch {
self.refresh_search_preview();
}
}
}
fn search_result_item(
result: &bcode_session_search::HydratedSessionSearchHit,
canonical_title: Option<&str>,
muted: Style,
) -> ListItem {
let preview = result.hit.preview.as_deref().unwrap_or("<no preview>");
let timestamp = result
.event
.as_deref()
.map(|event| format!(" @{}", event.timestamp_ms))
.unwrap_or_default();
let degraded = (result.outcome != bcode_session_search::SearchHitHydrationOutcome::Hydrated)
.then_some(format!(" [{:?}]", result.outcome))
.unwrap_or_default();
let title = canonical_title.unwrap_or("<canonical title unavailable>");
Line::from_spans(vec![
Span::styled(
format!(
"{title} {} #{} {:?}{timestamp}",
result.hit.locator.session_id, result.hit.locator.sequence, result.hit.content_kind
),
Style::new().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!(
"{} rank {}{degraded}",
result.hit.provider_id, result.hit.provider_rank
),
muted,
),
Span::raw(" "),
Span::raw(preview),
Span::raw(if result.hit.preview_truncated {
" [truncated]"
} else {
""
}),
])
}
fn session_item(session: &SessionSummary, muted: Style) -> ListItem {
let name = session.display_title();
let display_name = session.import.as_ref().map_or_else(
|| name.to_owned(),
|import| {
if import.imported_at_ms == 0 {
format!("[{} import] {name}", import.source_id)
} else {
format!("[{}] {name}", import.source_id)
}
},
);
let id = session.id.to_string();
let cwd = display_from_current_dir(&session.working_directory).to_string();
let mut spans = vec![
Span::styled(display_name, Style::new().add_modifier(Modifier::BOLD)),
Span::raw(" · "),
Span::styled(cwd, muted),
];
if let Some(label) = session_location_label(session) {
spans.push(Span::raw(" · "));
spans.push(Span::styled(label, muted));
}
spans.push(Span::raw(" · "));
spans.push(Span::styled(format!("#{}", &id[..8]), muted));
Line::from_spans(spans)
}
fn session_location_label(session: &SessionSummary) -> Option<String> {
let location = session.location.as_ref()?;
if location.ambiguous {
return Some("(ambiguous location — needs maintenance)".to_owned());
}
if location.primary {
return None;
}
Some(format!(
"({})",
location
.profile
.clone()
.unwrap_or_else(|| location.location_id.clone())
))
}
fn session_matches(session: &SessionSummary, query: &str) -> bool {
if query.is_empty() {
return true;
}
session.display_title().to_ascii_lowercase().contains(query)
|| session.id.to_string().contains(query)
|| session.import.as_ref().is_some_and(|import| {
import.source_id.to_ascii_lowercase().contains(query)
|| import
.source_display_name
.to_ascii_lowercase()
.contains(query)
})
|| display_from_current_dir(&session.working_directory)
.to_string()
.to_ascii_lowercase()
.contains(query)
|| session.location.as_ref().is_some_and(|location| {
location
.profile
.as_ref()
.is_some_and(|profile| profile.to_ascii_lowercase().contains(query))
|| location.location_id.to_ascii_lowercase().contains(query)
})
}
fn empty_item(message: &str, muted: Style) -> ListItem {
Line::from_spans(vec![Span::styled(message.to_owned(), muted)])
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(title: &str, working_directory: &str) -> SessionSummary {
SessionSummary {
id: SessionId::new(),
parent_id: None,
name: Some(title.to_owned()),
explicit_name: None,
derived_title: None,
automatic_title: None,
automatic_title_source_sequence: None,
title_source: bcode_session_models::SessionTitleSource::Explicit,
client_count: 0,
created_at_ms: 1,
updated_at_ms: 1,
working_directory: working_directory.into(),
import: None,
execution: None,
location: None,
}
}
fn located_summary(
title: &str,
profile: Option<&str>,
primary: bool,
ambiguous: bool,
) -> SessionSummary {
let mut session = summary(title, "/tmp/workspace");
session.location = Some(bcode_session_models::SessionLocationSummary {
location_id: "abcd1234".to_owned(),
profile: profile.map(str::to_owned),
primary,
ambiguous,
});
session
}
#[test]
fn scope_toggle_retains_query_clears_rows_and_is_remembered_on_reopen() {
let _scope_guard = SCOPE_TEST_LOCK.lock().unwrap();
let mut app = SessionPickerApp::new(vec![summary("東京 👩💻", "/tmp/B")]);
app.filter = super::super::text_input_flow::state_with_text("東京 query".to_owned(), false);
let initial = app.all_directories();
assert!(app.set_all_directories(!initial));
assert_eq!(app.filter().buffer().text(), "東京 query");
assert!(app.sessions.is_empty());
assert_eq!(
SessionPickerApp::remembered(Vec::new()).all_directories(),
!initial
);
assert!(!app.set_all_directories(!initial));
app.set_all_directories(initial);
}
#[test]
fn global_page_retains_backend_matches_and_children_without_resident_parents() {
let mut child = summary("child", "/tmp/B");
child.parent_id = Some(SessionId::new());
let mut app = SessionPickerApp::new(Vec::new());
app.catalog_scope = CatalogScope::AllDirectories;
app.filter = super::super::text_input_flow::state_with_text("backend-only match", false);
app.replace_sessions(vec![child.clone()]);
assert_eq!(app.selected_session_id(), Some(child.id));
assert_eq!(app.list.indices().len(), 1);
}
#[test]
fn empty_page_continuation_is_navigable_and_restart_discards_cursor() {
let mut app = SessionPickerApp::new(Vec::new());
let cursor = bcode_session_models::SessionCatalogCursor {
scope: bcode_session_models::SessionDiscoveryScope::AllWorkingDirectories,
query: bcode_session_models::SessionCatalogQuery::default(),
revision: 7,
location_index: 0,
after: None,
};
app.set_catalog_next_cursor(Some(cursor.clone()));
assert!(app.has_catalog_continuation());
assert!(app.next_catalog_page());
assert_eq!(app.catalog_cursor(), Some(&cursor));
assert!(!app.next_catalog_page());
app.restart_catalog();
assert!(app.catalog_cursor().is_none());
assert!(!app.has_catalog_continuation());
}
#[test]
fn catalog_loading_follows_top_until_manual_navigation() {
for scope in [CatalogScope::Directory, CatalogScope::AllDirectories] {
let oldest = summary("oldest", "/tmp/workspace");
let newer = summary("newer", "/tmp/workspace");
let newest = summary("newest", "/tmp/workspace");
let mut app = SessionPickerApp::new(Vec::new());
app.catalog_scope = scope;
app.replace_sessions(vec![oldest.clone()]);
app.replace_sessions(vec![newer.clone(), oldest.clone()]);
assert_eq!(app.selected_session_id(), Some(newer.id));
app.replace_sessions(vec![newest.clone(), newer.clone(), oldest.clone()]);
assert_eq!(app.selected_session_id(), Some(newest.id));
app.select_next();
app.replace_sessions(vec![oldest.clone(), newest.clone(), newer.clone()]);
assert_eq!(app.selected_session_id(), Some(newer.id));
}
}
#[test]
fn catalog_loading_preserves_mouse_and_upward_navigation() {
for mouse in [false, true] {
let first = summary("first", "/tmp/workspace");
let incoming = summary("incoming", "/tmp/workspace");
let mut app = SessionPickerApp::new(vec![first.clone()]);
if mouse {
assert!(app.select_visible(0));
} else {
app.select_previous();
}
app.replace_sessions(vec![incoming, first.clone()]);
assert_eq!(app.selected_session_id(), Some(first.id));
}
}
#[test]
fn catalog_reordering_preserves_selection_and_all_results_remain_reachable() {
let first = summary("first", "/tmp/workspace");
let selected = summary("selected", "/tmp/workspace");
let mut app = SessionPickerApp::new(vec![first.clone(), selected.clone()]);
app.select_next();
let mut sessions = (0..300)
.map(|index| summary(&format!("later {index}"), "/tmp/workspace"))
.collect::<Vec<_>>();
sessions.insert(17, selected.clone());
sessions.push(first);
app.replace_sessions(sessions);
assert_eq!(app.selected_session_id(), Some(selected.id));
assert_eq!(app.list.indices().len(), 302);
app.select_visible(0);
for index in 0..302 {
assert_eq!(app.selected_session_id(), Some(app.sessions[index].id));
app.select_next();
}
app.replace_sessions(vec![]);
assert_eq!(app.selected_session_id(), None);
}
#[test]
fn removed_catalog_target_cancels_mutation_instead_of_retargeting() {
for rename in [false, true] {
let selected = summary("selected", "/tmp/workspace");
let survivor = summary("survivor", "/tmp/workspace");
let mut app = SessionPickerApp::new(vec![selected.clone(), survivor.clone()]);
assert!(if rename {
app.start_rename()
} else {
app.start_delete_confirmation()
});
app.replace_sessions(vec![survivor.clone(), selected.clone()]);
assert_eq!(app.selected_session_id(), Some(selected.id));
assert_eq!(
app.mode(),
if rename {
SessionPickerMode::Rename
} else {
SessionPickerMode::DeleteConfirm
}
);
app.replace_sessions(vec![survivor.clone()]);
assert_eq!(app.mode(), SessionPickerMode::Filter);
assert_eq!(app.selected_session_id(), Some(survivor.id));
assert!(app.status().contains("action canceled"));
assert!(app.rename().buffer().text().is_empty());
}
}
#[test]
fn catalog_status_preserves_loading_errors_and_mutation_prompts() {
use bcode_session_models::SessionCatalogStatus;
let mut app = SessionPickerApp::new(vec![summary("selected", "/tmp/workspace")]);
app.set_catalog_status(&SessionCatalogStatus::Loading);
assert!(app.status().contains("discovering"));
app.set_catalog_status(&SessionCatalogStatus::Degraded("offline source".to_owned()));
assert!(app.status().contains("offline source"));
assert!(app.empty_message.contains("offline source"));
app.set_catalog_status(&SessionCatalogStatus::Loaded);
assert_eq!(app.status(), "1 sessions");
app.start_rename();
let status = app.status().to_owned();
app.set_catalog_status(&SessionCatalogStatus::Loading);
assert_eq!(app.status(), status);
}
#[test]
fn primary_location_is_not_labeled_but_foreign_locations_are() {
assert_eq!(
super::session_location_label(&located_summary("a", None, true, false)),
None,
"the common single-location case must not be labeled"
);
assert_eq!(
super::session_location_label(&located_summary("b", Some("big"), false, false)),
Some("(big)".to_owned())
);
assert_eq!(
super::session_location_label(&located_summary("c", None, false, false)),
Some("(abcd1234)".to_owned()),
"an unnamed foreign location falls back to its opaque identity"
);
assert_eq!(
super::session_location_label(&summary("d", "/tmp/workspace")),
None
);
}
#[test]
fn ambiguous_locations_are_always_labeled_even_when_primary() {
let label = super::session_location_label(&located_summary("a", None, true, true))
.expect("ambiguity must always be surfaced");
assert!(label.contains("ambiguous"), "{label}");
assert!(label.contains("maintenance"), "{label}");
}
#[test]
fn session_filter_matches_location_profile_and_identity() {
let session = located_summary("unrelated title", Some("bigdrive"), false, false);
assert!(super::session_matches(&session, "bigdrive"));
assert!(super::session_matches(&session, "abcd1234"));
assert!(!super::session_matches(&session, "nomatch"));
}
fn sample_search_result(
outcome: bcode_session_search::SearchHitHydrationOutcome,
) -> bcode_session_search::HydratedSessionSearchHit {
let session_id = SessionId::new();
bcode_session_search::HydratedSessionSearchHit {
hit: bcode_session_search::SessionSearchHit {
locator: bcode_session_search::SessionSearchLocator {
session_id,
sequence: 7,
record_id: Some("result".to_owned()),
},
content_kind: bcode_session_search::SearchContentKind::AssistantMessage,
matched_field: bcode_session_search::SearchField::Text,
provider_id: "provider".to_owned(),
provider_rank: 1,
provider_score: None,
preview: Some("bounded preview".to_owned()),
preview_truncated: false,
},
outcome,
event: (outcome == bcode_session_search::SearchHitHydrationOutcome::Hydrated).then(
|| {
Box::new(bcode_session_models::SessionEvent {
schema_version: bcode_session_models::CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence: 7,
timestamp_ms: 99,
session_id,
provenance: None,
kind: bcode_session_models::SessionEventKind::AssistantMessage {
text: "canonical".to_owned(),
},
})
},
),
message: None,
}
}
use bmux_tui::event::{MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::Point;
fn mouse(kind: MouseEventKind, x: u16, y: u16) -> MouseEvent {
MouseEvent::new(kind, Point::new(x, y))
}
#[test]
fn transcript_search_mouse_row_selects_result_and_refreshes_preview() {
let mut app = SessionPickerApp::new(Vec::new());
let first = sample_search_result(bcode_session_search::SearchHitHydrationOutcome::Hydrated);
let mut second =
sample_search_result(bcode_session_search::SearchHitHydrationOutcome::Hydrated);
second.hit.provider_rank = 2;
second.hit.preview = Some("second preview".to_owned());
let response = bcode_session_search::FederatedSessionSearchResponse {
hits: vec![first.hit.clone(), second.hit.clone()],
query_complete: true,
coverage_complete: true,
providers: Vec::new(),
failures: Vec::new(),
};
app.set_search_results(&response, vec![first, second]);
let row = super::super::picker_mouse::picker_row_from_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
4,
6,
))
.expect("second visible row");
assert!(app.select_visible(row));
assert_eq!(
app.selected_search_result()
.map(|result| result.hit.provider_rank),
Some(2)
);
assert!(app.search_preview().contains("second preview"));
let selected = app.selected_search_result().cloned();
let preview = app.search_preview().to_owned();
app.replace_sessions(vec![summary("unrelated metadata", "/tmp/workspace")]);
assert_eq!(app.selected_search_result(), selected.as_ref());
assert_eq!(app.search_preview(), preview);
}
#[test]
fn transcript_search_mode_accepts_query_before_results_exist() {
let mut app = SessionPickerApp::new(Vec::new());
app.start_transcript_search();
assert_eq!(app.mode(), SessionPickerMode::TranscriptSearch);
assert!(app.selected_search_result().is_none());
assert!(app.status().contains("press Enter"));
}
#[test]
fn transcript_results_preserve_all_rows_coverage_and_selection() {
let mut app = SessionPickerApp::new(Vec::new());
let first = sample_search_result(bcode_session_search::SearchHitHydrationOutcome::Hydrated);
let mut canonical = summary("Canonical title", "/workspace");
canonical.id = first.hit.locator.session_id;
app.replace_sessions(vec![canonical]);
let mut second =
sample_search_result(bcode_session_search::SearchHitHydrationOutcome::SessionMissing);
second.hit.provider_rank = 2;
let response = bcode_session_search::FederatedSessionSearchResponse {
hits: vec![first.hit.clone(), second.hit.clone()],
query_complete: false,
coverage_complete: false,
providers: Vec::new(),
failures: Vec::new(),
};
app.set_search_results(&response, vec![first.clone(), second]);
assert_eq!(app.mode(), SessionPickerMode::TranscriptSearch);
let items = app.list_items(Style::new());
assert_eq!(items.len(), 2);
let first_text = items[0]
.spans
.iter()
.map(|span| span.content.as_str())
.collect::<String>();
assert!(first_text.contains("Canonical title"));
assert!(first_text.contains("@99"));
assert!(app.status().contains("query incomplete"));
assert_eq!(app.selected_search_result(), Some(&first));
app.select_next();
assert_eq!(
app.selected_search_result()
.map(|result| result.hit.provider_rank),
Some(2)
);
app.close_search_results();
assert_eq!(app.mode(), SessionPickerMode::Filter);
}
#[test]
fn transcript_status_keeps_query_coverage_and_failures_independent() {
let cases = [
(
true,
true,
0,
[
"query complete",
"searchable coverage complete",
"no provider failures",
],
),
(
false,
true,
0,
[
"query incomplete",
"searchable coverage complete",
"no provider failures",
],
),
(
true,
false,
0,
[
"query complete",
"searchable coverage incomplete",
"no provider failures",
],
),
(
true,
true,
1,
[
"query complete",
"searchable coverage complete",
"1 provider failures",
],
),
];
for (query_complete, coverage_complete, failures, expected) in cases {
let mut app = SessionPickerApp::new(Vec::new());
let response = bcode_session_search::FederatedSessionSearchResponse {
hits: Vec::new(),
query_complete,
coverage_complete,
providers: Vec::new(),
failures: (0..failures)
.map(|_| bcode_session_search::SessionSearchProviderFailure {
plugin_id: "provider".to_owned(),
error: bcode_session_search::SessionSearchServiceError {
code: bcode_session_search::SearchErrorCode::ProviderUnavailable,
message: "unavailable".to_owned(),
retryable: true,
},
stage: bcode_session_search::SessionSearchProviderStage::Execution,
elapsed_ms: 0,
content: Vec::new(),
})
.collect(),
};
app.set_search_results(&response, Vec::new());
for phrase in expected {
assert!(
app.status().contains(phrase),
"missing {phrase}: {}",
app.status()
);
}
}
}
#[test]
fn dedicated_search_state_controls_request_preview_and_human_status() {
let mut app = SessionPickerApp::new(vec![summary("one", "/one")]);
app.start_transcript_search();
app.cycle_search_match_mode();
app.toggle_search_depth();
app.cycle_search_sort();
let (request, policy) = app.search_request("needle", false).expect("search request");
assert!(matches!(
request.query,
bcode_session_search::SessionSearchQuery::Text {
mode: bcode_session_search::TextMatchMode::Phrase,
..
}
));
assert_eq!(
request.sort,
bcode_session_search::SessionSearchSort::NewestFirst
);
assert_eq!(
policy.execution_class,
bcode_session_search::SessionSearchExecutionClass::Deep
);
let hit = sample_search_result(bcode_session_search::SearchHitHydrationOutcome::Hydrated);
let response = bcode_session_search::FederatedSessionSearchResponse {
hits: vec![hit.hit.clone()],
query_complete: true,
coverage_complete: true,
providers: Vec::new(),
failures: Vec::new(),
};
app.set_search_results(&response, vec![hit]);
assert!(app.status().contains("searchable coverage complete"));
assert!(!app.status().contains("query_complete="));
assert!(!app.search_preview().is_empty());
}
#[test]
fn maintenance_controls_require_confirmation_and_retain_cancellable_operations() {
let mut app = SessionPickerApp::new(Vec::new());
assert!(!app.confirm_canonical_migration());
assert!(app.confirm_canonical_migration());
assert!(!app.confirm_canonical_migration());
app.set_bulk_migration_operation_id("migration-1".to_owned());
app.set_search_backfill_operation_id("backfill-1".to_owned());
assert_eq!(
app.take_bulk_migration_operation_id().as_deref(),
Some("migration-1")
);
assert!(app.take_bulk_migration_operation_id().is_none());
assert_eq!(
app.take_search_backfill_operation_id().as_deref(),
Some("backfill-1")
);
}
#[test]
fn filter_matches_only_portable_summary_metadata() {
let mut session = summary("Visible title", "/workspace/project");
session.import = Some(bcode_session_models::SessionImportSummary {
source_id: "opencode".to_owned(),
source_display_name: "OpenCode".to_owned(),
external_session_id: "external".to_owned(),
imported_at_ms: 1,
});
assert!(session_matches(&session, "visible"));
assert!(session_matches(&session, &session.id.to_string()));
assert!(session_matches(&session, "workspace/project"));
assert!(session_matches(&session, "opencode"));
assert!(!session_matches(&session, "parent title"));
assert!(!session_matches(&session, "transcript-only-term"));
assert!(!session_matches(&session, "external"));
}
}