aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-03-09 13:31:08 +0100
committerGitHub <noreply@github.com>2026-03-09 13:31:08 +0100
commite7985f0d77c4ee847d014b808eeede55d20f6d79 (patch)
treee41151bedba3bc525c117db3d82e264ac63464f9 /default-plugins
parentAdd cli paste action (#4817) (diff)
downloadsl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.tar.gz
sl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.tar.xz
sl0ppij-e7985f0d77c4ee847d014b808eeede55d20f6d79.zip
Single screen session manager (#4821)
* initial implementation * adjust ui * preserve selection * width responsiveness * behavior fixes * fix: do not count suppressed panes * ui improvements * multi-screen config and prompt hint * tests + refactoring * improve performance * add pr
Diffstat (limited to 'default-plugins')
-rw-r--r--default-plugins/session-manager/src/main.rs624
-rw-r--r--default-plugins/session-manager/src/new_session_info.rs287
-rw-r--r--default-plugins/session-manager/src/single_screen.rs939
-rw-r--r--default-plugins/session-manager/src/ui/components.rs676
-rw-r--r--default-plugins/session-manager/src/ui/mod.rs5
5 files changed, 2461 insertions, 70 deletions
diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs
index 85b120e7..59bdf258 100644
--- a/default-plugins/session-manager/src/main.rs
+++ b/default-plugins/session-manager/src/main.rs
@@ -1,16 +1,19 @@
mod new_session_info;
mod resurrectable_sessions;
mod session_list;
+mod single_screen;
mod ui;
use std::collections::BTreeMap;
use uuid::Uuid;
use zellij_tile::prelude::*;
use new_session_info::NewSessionInfo;
+use single_screen::{SingleScreenMode, SingleScreenState, UnifiedSearchResult};
use ui::{
components::{
render_controls_line, render_error, render_new_session_block, render_prompt,
- render_renaming_session_screen, render_screen_toggle, render_unsaved_changes_line, Colors,
+ render_renaming_session_screen, render_screen_toggle, render_single_screen_prompt,
+ render_unified_results, render_unsaved_changes_line, Colors,
},
welcome_screen::{render_banner, render_welcome_boundaries},
SessionUiInfo,
@@ -24,6 +27,7 @@ enum ActiveScreen {
NewSession,
AttachToSession,
ResurrectSession,
+ SingleScreen,
}
impl Default for ActiveScreen {
@@ -44,6 +48,8 @@ struct State {
active_screen: ActiveScreen,
colors: Colors,
is_welcome_screen: bool,
+ is_multi_screen: bool,
+ single_screen_state: SingleScreenState,
show_kill_all_sessions_warning: bool,
request_ids: Vec<String>,
is_web_client: bool,
@@ -62,6 +68,14 @@ impl ZellijPlugin for State {
self.active_screen = ActiveScreen::NewSession;
}
self.new_session_info.is_welcome_screen = self.is_welcome_screen;
+ self.is_multi_screen = configuration
+ .get("multi_screen")
+ .map(|v| v == "true")
+ .unwrap_or(false);
+ if !self.is_multi_screen {
+ self.active_screen = ActiveScreen::SingleScreen;
+ }
+ self.single_screen_state.is_welcome_screen = self.is_welcome_screen;
if !self.is_welcome_screen {
set_timeout(0.1); // for the current_session_last_saved_time polling
}
@@ -82,6 +96,10 @@ impl ZellijPlugin for State {
Some(request_id_position) => {
self.request_ids.remove(request_id_position);
let new_session_folder = std::path::PathBuf::from(payload);
+ if !self.is_multi_screen {
+ self.single_screen_state.new_session_folder =
+ Some(new_session_folder.clone());
+ }
self.new_session_info.new_session_folder = Some(new_session_folder);
},
None => {
@@ -100,9 +118,12 @@ impl ZellijPlugin for State {
let mut should_render = false;
match event {
Event::Timer(_) => {
- self.current_session_last_saved_time = current_session_last_saved_time();
+ let new_saved_time = current_session_last_saved_time();
+ if new_saved_time != self.current_session_last_saved_time {
+ self.current_session_last_saved_time = new_saved_time;
+ should_render = true;
+ }
set_timeout(1.0);
- should_render = true;
},
Event::ModeUpdate(mode_info) => {
self.colors = Colors::new(mode_info.style.colors);
@@ -125,6 +146,14 @@ impl ZellijPlugin for State {
self.resurrectable_sessions
.update(resurrectable_session_list);
self.update_session_infos(session_infos);
+ if !self.is_multi_screen {
+ self.single_screen_state.update_search_term(
+ &self.sessions.session_ui_infos,
+ &self.resurrectable_sessions.all_resurrectable_sessions,
+ );
+ self.single_screen_state.layout_list =
+ self.new_session_info.get_layout_list_clone();
+ }
should_render = true;
},
_ => (),
@@ -140,13 +169,16 @@ impl ZellijPlugin for State {
if self.is_welcome_screen {
render_banner(x, 0, rows.saturating_sub(height), width);
}
- render_screen_toggle(
- self.active_screen,
- x,
- y,
- width.saturating_sub(2),
- &background,
- );
+
+ if self.active_screen != ActiveScreen::SingleScreen {
+ render_screen_toggle(
+ self.active_screen,
+ x,
+ y,
+ width.saturating_sub(2),
+ &background,
+ );
+ }
match self.active_screen {
ActiveScreen::NewSession => {
@@ -180,19 +212,174 @@ impl ZellijPlugin for State {
ActiveScreen::ResurrectSession => {
self.resurrectable_sessions.render(height, width, x, y);
},
+ ActiveScreen::SingleScreen => {
+ match self.single_screen_state.mode {
+ SingleScreenMode::SearchAndSelect => {
+ if let Some(new_session_name) = self.renaming_session_name.as_ref() {
+ render_renaming_session_screen(new_session_name, height, width, x, y);
+ } else if self.show_kill_all_sessions_warning {
+ self.render_kill_all_sessions_warning(height, width, x, y);
+ } else {
+ // Use max_table_rows as fixed content height so the
+ // prompt position stays stable regardless of result count
+ let max_table_rows = height.saturating_sub(5);
+ let content_height = 2 + max_table_rows; // prompt + header + max data rows
+ // Available space above help lines (2 help rows at bottom)
+ let available = height.saturating_sub(3);
+ let y_offset = y + available.saturating_sub(content_height) / 2;
+
+ // Horizontal centering: cap content block and center
+ // within the full pane width
+ let content_width = std::cmp::min(width, 90);
+ let x_centered = x + (width.saturating_sub(content_width)) / 2;
+
+ let enter_action = if !self.single_screen_state.search_term.is_empty() {
+ if let Some(result) = self.single_screen_state.get_selected_result()
+ {
+ match result {
+ UnifiedSearchResult::ActiveSession { .. } => Some("Attach"),
+ UnifiedSearchResult::ResurrectableSession { .. } => {
+ Some("Resurrect")
+ },
+ }
+ } else {
+ let typed = &self.single_screen_state.search_term;
+ if self.sessions.has_session(typed) {
+ Some("Attach")
+ } else if self.resurrectable_sessions.has_session(typed) {
+ Some("Resurrect")
+ } else {
+ Some("Create new")
+ }
+ }
+ } else {
+ None
+ };
+ render_single_screen_prompt(
+ &self.single_screen_state.search_term,
+ enter_action,
+ self.colors,
+ x_centered,
+ y_offset,
+ );
+ render_unified_results(
+ &self.single_screen_state.render_cache,
+ self.single_screen_state.selected_index,
+ max_table_rows,
+ content_width,
+ self.colors,
+ x_centered,
+ y_offset + 2,
+ );
+ }
+ },
+ SingleScreenMode::SelectingLayout => {
+ let new_session_name = if self.single_screen_state.search_term.is_empty() {
+ "<RANDOM>"
+ } else {
+ &self.single_screen_state.search_term
+ };
+ let esc = self.colors.shortcuts("<ESC>");
+ println!(
+ "\u{1b}[m\u{1b}[{};{}H{}: {} ({} to go back)",
+ y + 1,
+ x + 1,
+ self.colors.session_name_prompt("New session name"),
+ self.colors.session_and_folder_entry(new_session_name),
+ esc,
+ );
+
+ // Render layout selection
+ let layout_search_term =
+ &self.single_screen_state.layout_list.layout_search_term;
+ let search_term_len = layout_search_term.len();
+ let layout_indication_line = if width > 73 + search_term_len {
+ Text::new(format!(
+ "New session layout: {}_ (Search and select from list, <ENTER> when done)",
+ layout_search_term
+ ))
+ .color_range(2, ..20 + search_term_len)
+ .color_range(3, 20..20 + search_term_len)
+ .color_range(3, 52 + search_term_len..59 + search_term_len)
+ } else {
+ Text::new(format!(
+ "New session layout: {}_ <ENTER>",
+ layout_search_term
+ ))
+ .color_range(2, ..20 + search_term_len)
+ .color_range(3, 20..20 + search_term_len)
+ .color_range(3, 22 + search_term_len..)
+ };
+ print_text_with_coordinates(layout_indication_line, x, y + 2, None, None);
+ println!();
+
+ let max_layout_rows = height.saturating_sub(8);
+ let mut table = Table::new();
+ for (i, (layout_info, indices, is_selected)) in self
+ .single_screen_state
+ .layout_list
+ .layouts_to_render(max_layout_rows)
+ .into_iter()
+ .enumerate()
+ {
+ let layout_name = layout_info.name();
+ let layout_name_len = layout_name.len();
+ let is_builtin = layout_info.is_builtin();
+ if i > max_layout_rows.saturating_sub(1) {
+ break;
+ }
+ let mut layout_cell = if is_builtin {
+ Text::new(format!("{} (built-in)", layout_name))
+ .color_range(1, 0..layout_name_len)
+ .color_range(0, layout_name_len + 1..)
+ .color_indices(3, indices)
+ } else {
+ Text::new(format!("{}", layout_name))
+ .color_range(1, ..)
+ .color_indices(3, indices)
+ };
+ if is_selected {
+ layout_cell = layout_cell.selected();
+ }
+ let arrow_cell = if is_selected {
+ Text::new(format!("<↓↑>")).selected().color_range(3, ..)
+ } else {
+ Text::new(format!(" ")).color_range(3, ..)
+ };
+ table = table.add_styled_row(vec![arrow_cell, layout_cell]);
+ }
+ print_table_with_coordinates(table, x, y + 4, None, None);
+
+ // Render folder prompt
+ self.render_single_screen_folder_prompt(
+ x,
+ (y + height).saturating_sub(3),
+ width,
+ );
+ },
+ }
+ },
}
if let Some(error) = self.error.as_ref() {
render_error(&error, height, width, x, y);
- } else if self.active_screen == ActiveScreen::AttachToSession && !self.is_welcome_screen {
+ } else if (self.active_screen == ActiveScreen::AttachToSession
+ || self.active_screen == ActiveScreen::SingleScreen)
+ && !self.is_welcome_screen
+ {
+ let help_x = if self.active_screen == ActiveScreen::SingleScreen {
+ let content_width = std::cmp::min(width, 90);
+ x + (width.saturating_sub(content_width)) / 2
+ } else {
+ x
+ };
let help_offset = render_controls_line(
self.active_screen,
width,
self.colors,
- x,
+ help_x,
rows.saturating_sub(1),
);
- // Adjust position and width based on whether controls line shows "Help: "
- let adjusted_x = x + help_offset;
+ let adjusted_x = help_x + help_offset;
let adjusted_width = width.saturating_sub(help_offset);
render_unsaved_changes_line(
adjusted_width,
@@ -223,6 +410,7 @@ impl State {
ActiveScreen::NewSession => self.handle_new_session_key(key),
ActiveScreen::AttachToSession => self.handle_attach_to_session(key),
ActiveScreen::ResurrectSession => self.handle_resurrect_session_key(key),
+ ActiveScreen::SingleScreen => self.handle_single_screen_key(key),
}
}
fn handle_new_session_key(&mut self, key: KeyWithModifier) -> bool {
@@ -489,6 +677,238 @@ impl State {
}
should_render
}
+ fn handle_single_screen_key(&mut self, key: KeyWithModifier) -> bool {
+ match self.single_screen_state.mode {
+ SingleScreenMode::SearchAndSelect => self.handle_single_screen_search_key(key),
+ SingleScreenMode::SelectingLayout => self.handle_single_screen_layout_key(key),
+ }
+ }
+ fn handle_single_screen_search_key(&mut self, key: KeyWithModifier) -> bool {
+ let mut should_render = false;
+
+ // Handle kill-all warning overlay first
+ if self.show_kill_all_sessions_warning {
+ match key.bare_key {
+ BareKey::Char('y') if key.has_no_modifiers() => {
+ let all_other_sessions = self.sessions.all_other_sessions();
+ kill_sessions(&all_other_sessions);
+ self.show_kill_all_sessions_warning = false;
+ should_render = true;
+ },
+ BareKey::Char('n') | BareKey::Esc if key.has_no_modifiers() => {
+ self.show_kill_all_sessions_warning = false;
+ should_render = true;
+ },
+ BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ self.show_kill_all_sessions_warning = false;
+ should_render = true;
+ },
+ _ => {},
+ }
+ return should_render;
+ }
+
+ // Handle rename overlay
+ if self.renaming_session_name.is_some() {
+ match key.bare_key {
+ BareKey::Enter if key.has_no_modifiers() => {
+ self.handle_selection();
+ should_render = true;
+ },
+ BareKey::Char(c) if key.has_no_modifiers() => {
+ if c == '\n' {
+ self.handle_selection();
+ } else if let Some(name) = self.renaming_session_name.as_mut() {
+ name.push(c);
+ }
+ should_render = true;
+ },
+ BareKey::Backspace if key.has_no_modifiers() => {
+ if let Some(name) = self.renaming_session_name.as_mut() {
+ if name.is_empty() {
+ self.renaming_session_name = None;
+ } else {
+ name.pop();
+ }
+ }
+ should_render = true;
+ },
+ BareKey::Esc if key.has_no_modifiers() => {
+ self.renaming_session_name = None;
+ should_render = true;
+ },
+ _ => {},
+ }
+ return should_render;
+ }
+
+ match key.bare_key {
+ BareKey::Char(character) if key.has_no_modifiers() => {
+ if character == '\n' {
+ self.handle_selection();
+ } else {
+ self.single_screen_state.search_term.push(character);
+ self.single_screen_state.update_search_term(
+ &self.sessions.session_ui_infos,
+ &self.resurrectable_sessions.all_resurrectable_sessions,
+ );
+ }
+ should_render = true;
+ },
+ BareKey::Backspace if key.has_no_modifiers() => {
+ self.single_screen_state.search_term.pop();
+ self.single_screen_state.update_search_term(
+ &self.sessions.session_ui_infos,
+ &self.resurrectable_sessions.all_resurrectable_sessions,
+ );
+ should_render = true;
+ },
+ BareKey::Enter if key.has_no_modifiers() => {
+ self.handle_selection();
+ should_render = true;
+ },
+ BareKey::Down if key.has_no_modifiers() => {
+ self.single_screen_state.move_selection_down();
+ should_render = true;
+ },
+ BareKey::Up if key.has_no_modifiers() => {
+ self.single_screen_state.move_selection_up();
+ should_render = true;
+ },
+ BareKey::Tab if key.has_no_modifiers() => {
+ self.single_screen_state.tab_complete(
+ &self.sessions.session_ui_infos,
+ &self.resurrectable_sessions.all_resurrectable_sessions,
+ );
+ should_render = true;
+ },
+ BareKey::Char('r') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ self.renaming_session_name = Some(String::new());
+ should_render = true;
+ },
+ BareKey::Delete if key.has_no_modifiers() => {
+ if let Some(result) = self.single_screen_state.get_selected_result() {
+ match result {
+ UnifiedSearchResult::ActiveSession { session_name, .. } => {
+ kill_sessions(&[session_name.clone()]);
+ },
+ UnifiedSearchResult::ResurrectableSession { session_name, .. } => {
+ delete_dead_session(session_name);
+ },
+ }
+ self.single_screen_state.selected_index = None;
+ }
+ should_render = true;
+ },
+ BareKey::Char('d') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ let all_other_sessions = self.sessions.all_other_sessions();
+ if all_other_sessions.is_empty() {
+ self.show_error("No other sessions to kill. Quit to kill the current one.");
+ } else {
+ self.show_kill_all_sessions_warning = true;
+ }
+ should_render = true;
+ },
+ BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ disconnect_other_clients();
+ },
+ BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ if !self.is_welcome_screen {
+ if let Err(e) = save_session() {
+ self.show_error(&format!("Couldn't save session: {}", e));
+ }
+ }
+ },
+ BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ if !self.single_screen_state.search_term.is_empty() {
+ self.single_screen_state.search_term.clear();
+ self.single_screen_state.update_search_term(
+ &self.sessions.session_ui_infos,
+ &self.resurrectable_sessions.all_resurrectable_sessions,
+ );
+ } else if !self.is_welcome_screen {
+ hide_self();
+ }
+ should_render = true;
+ },
+ BareKey::Esc if key.has_no_modifiers() => {
+ if self.single_screen_state.selected_index.is_some() {
+ self.single_screen_state.selected_index = None;
+ should_render = true;
+ } else if !self.is_welcome_screen {
+ hide_self();
+ }
+ },
+ _ => {},
+ }
+ should_render
+ }
+ fn handle_single_screen_layout_key(&mut self, key: KeyWithModifier) -> bool {
+ let mut should_render = false;
+ match key.bare_key {
+ BareKey::Down if key.has_no_modifiers() => {
+ self.single_screen_state.layout_list.move_selection_down();
+ should_render = true;
+ },
+ BareKey::Up if key.has_no_modifiers() => {
+ self.single_screen_state.layout_list.move_selection_up();
+ should_render = true;
+ },
+ BareKey::Enter if key.has_no_modifiers() => {
+ self.handle_selection();
+ should_render = true;
+ },
+ BareKey::Char(character) if key.has_no_modifiers() => {
+ if character == '\n' {
+ self.handle_selection();
+ } else {
+ self.single_screen_state
+ .layout_list
+ .layout_search_term
+ .push(character);
+ self.single_screen_state.layout_list.update_search_term();
+ }
+ should_render = true;
+ },
+ BareKey::Backspace if key.has_no_modifiers() => {
+ self.single_screen_state
+ .layout_list
+ .layout_search_term
+ .pop();
+ self.single_screen_state.layout_list.update_search_term();
+ should_render = true;
+ },
+ BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ let request_id = Uuid::new_v4();
+ let mut config = BTreeMap::new();
+ let mut args = BTreeMap::new();
+ self.request_ids.push(request_id.to_string());
+ config.insert("request_id".to_owned(), request_id.to_string());
+ args.insert("request_id".to_owned(), request_id.to_string());
+ pipe_message_to_plugin(
+ MessageToPlugin::new("filepicker")
+ .with_plugin_url("filepicker")
+ .with_plugin_config(config)
+ .new_plugin_instance_should_have_pane_title(
+ "Select folder for the new session...",
+ )
+ .new_plugin_instance_should_be_focused()
+ .with_args(args),
+ );
+ should_render = true;
+ },
+ BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
+ self.single_screen_state.new_session_folder = None;
+ should_render = true;
+ },
+ BareKey::Esc if key.has_no_modifiers() => {
+ self.single_screen_state.transition_to_search();
+ should_render = true;
+ },
+ _ => {},
+ }
+ should_render
+ }
fn handle_selection(&mut self) {
match self.active_screen {
ActiveScreen::NewSession => {
@@ -588,6 +1008,137 @@ impl State {
}
}
},
+ ActiveScreen::SingleScreen => {
+ // Handle rename
+ if let Some(renaming_session_name) = &self.renaming_session_name.take() {
+ if renaming_session_name.is_empty() {
+ self.show_error("New name must not be empty.");
+ return;
+ } else if self.session_name.as_ref() == Some(renaming_session_name) {
+ return;
+ } else if self.sessions.has_session(&renaming_session_name) {
+ self.show_error("A session by this name already exists.");
+ return;
+ } else if self
+ .resurrectable_sessions
+ .has_session(&renaming_session_name)
+ {
+ self.show_error("A resurrectable session by this name already exists.");
+ return;
+ } else {
+ if renaming_session_name.contains('/') {
+ self.show_error("Session names cannot contain '/'");
+ return;
+ }
+ self.update_current_session_name_in_ui(&renaming_session_name);
+ rename_session(&renaming_session_name);
+ return;
+ }
+ }
+
+ match self.single_screen_state.mode {
+ SingleScreenMode::SearchAndSelect => {
+ if let Some(result) = self.single_screen_state.get_selected_result() {
+ // User navigated to a specific result
+ let session_name = result.session_name().to_owned();
+ match result {
+ UnifiedSearchResult::ActiveSession {
+ is_current_session, ..
+ } => {
+ if *is_current_session {
+ self.show_error("Already attached...");
+ } else {
+ switch_session_with_focus(&session_name, None, None);
+ }
+ },
+ UnifiedSearchResult::ResurrectableSession { .. } => {
+ switch_session(Some(&session_name));
+ },
+ }
+ self.single_screen_state.search_term.clear();
+ self.single_screen_state.selected_index = None;
+ if self.is_welcome_screen {
+ quit_zellij();
+ } else {
+ hide_self();
+ }
+ } else {
+ // No navigation - use typed name
+ let typed_name = self.single_screen_state.search_term.clone();
+
+ // Validate name
+ if typed_name.len() >= 108 {
+ self.show_error("Session name must be shorter than 108 bytes");
+ return;
+ }
+ if typed_name.contains('/') {
+ self.show_error("Session name cannot contain '/'");
+ return;
+ }
+ if self.sessions.has_forbidden_session(&typed_name) {
+ self.show_error(
+ "This session exists and web clients cannot attach to it.",
+ );
+ return;
+ }
+
+ // Check exact match against active sessions
+ if self.sessions.has_session(&typed_name) {
+ if self.session_name.as_deref() == Some(&typed_name) {
+ self.show_error("Already attached...");
+ } else {
+ switch_session_with_focus(&typed_name, None, None);
+ if self.is_welcome_screen {
+ quit_zellij();
+ } else {
+ hide_self();
+ }
+ }
+ return;
+ }
+ // Check exact match against resurrectable sessions
+ if self.resurrectable_sessions.has_session(&typed_name) {
+ switch_session(Some(&typed_name));
+ if self.is_welcome_screen {
+ quit_zellij();
+ } else {
+ hide_self();
+ }
+ return;
+ }
+ // No match - transition to layout selection
+ self.single_screen_state.transition_to_layout_selection();
+ }
+ },
+ SingleScreenMode::SelectingLayout => {
+ let new_session_name = if self.single_screen_state.search_term.is_empty() {
+ None
+ } else {
+ Some(self.single_screen_state.search_term.as_str())
+ };
+ let layout = self.single_screen_state.layout_list.selected_layout_info();
+ let cwd = self.single_screen_state.new_session_folder.clone();
+
+ if new_session_name != self.session_name.as_ref().map(|s| s.as_str()) {
+ match layout {
+ Some(layout_info) => {
+ switch_session_with_layout(new_session_name, layout_info, cwd);
+ },
+ None => {
+ switch_session(new_session_name);
+ },
+ }
+ }
+ self.single_screen_state.search_term.clear();
+ self.single_screen_state.transition_to_search();
+ if self.is_welcome_screen {
+ quit_zellij();
+ } else {
+ hide_self();
+ }
+ },
+ }
+ },
}
}
fn toggle_active_screen(&mut self) {
@@ -595,6 +1146,7 @@ impl State {
ActiveScreen::NewSession => ActiveScreen::AttachToSession,
ActiveScreen::AttachToSession => ActiveScreen::ResurrectSession,
ActiveScreen::ResurrectSession => ActiveScreen::NewSession,
+ ActiveScreen::SingleScreen => ActiveScreen::SingleScreen, // no-op
};
}
fn show_error(&mut self, error_text: &str) {
@@ -668,6 +1220,50 @@ impl State {
let height = rows.saturating_sub(y);
(x, y, width, height)
}
+ fn render_single_screen_folder_prompt(&self, x: usize, y: usize, max_cols: usize) {
+ match self.single_screen_state.new_session_folder.as_ref() {
+ Some(new_session_folder) => {
+ let folder_prompt = "New session folder:";
+ let new_session_folder_str = new_session_folder.display().to_string();
+ let change_folder_shortcut = self.colors.shortcuts("<Ctrl f>");
+ let reset_folder_shortcut = self.colors.shortcuts("<Ctrl c>");
+ if max_cols >= folder_prompt.len() + new_session_folder_str.len() + 30 {
+ print!(
+ "\u{1b}[m\u{1b}[{};{}H{} {} ({} to change, {} to reset)",
+ y + 1,
+ x + 1,
+ self.colors.session_name_prompt(folder_prompt),
+ self.colors
+ .session_and_folder_entry(&new_session_folder_str),
+ change_folder_shortcut,
+ reset_folder_shortcut,
+ );
+ } else {
+ print!(
+ "\u{1b}[m\u{1b}[{};{}H{} {} ({}/{})",
+ y + 1,
+ x + 1,
+ self.colors.session_name_prompt("Folder:"),
+ self.colors
+ .session_and_folder_entry(&new_session_folder_str),
+ change_folder_shortcut,
+ reset_folder_shortcut,
+ );
+ }
+ },
+ None => {
+ let folder_prompt = "New session folder:";
+ let change_folder_shortcut = self.colors.shortcuts("<Ctrl f>");
+ print!(
+ "\u{1b}[m\u{1b}[{};{}H{} ({} to set)",
+ y + 1,
+ x + 1,
+ self.colors.session_name_prompt(folder_prompt),
+ change_folder_shortcut,
+ );
+ },
+ }
+ }
fn render_kill_all_sessions_warning(&self, rows: usize, columns: usize, x: usize, y: usize) {
if rows == 0 || columns == 0 {
return;
diff --git a/default-plugins/session-manager/src/new_session_info.rs b/default-plugins/session-manager/src/new_session_info.rs
index cc8c4af0..39e9be90 100644
--- a/default-plugins/session-manager/src/new_session_info.rs
+++ b/default-plugins/session-manager/src/new_session_info.rs
@@ -142,7 +142,7 @@ impl NewSessionInfo {
}
pub fn layout_list(&self, max_rows: usize) -> Vec<(LayoutInfo, bool)> {
// bool - is_selected
- let range_to_render = self.range_to_render(
+ let rtr = range_to_render(
max_rows,
self.layout_count(),
Some(self.layout_list.selected_layout_index),
@@ -152,8 +152,8 @@ impl NewSessionInfo {
.iter()
.enumerate()
.map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index))
- .take(range_to_render.1)
- .skip(range_to_render.0)
+ .take(rtr.1)
+ .skip(rtr.0)
.collect()
}
pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> {
@@ -180,7 +180,7 @@ impl NewSessionInfo {
}
pub fn layout_search_results(&self, max_rows: usize) -> Vec<(LayoutSearchResult, bool)> {
// bool - is_selected
- let range_to_render = self.range_to_render(
+ let rtr = range_to_render(
max_rows,
self.layout_list.layout_search_results.len(),
Some(self.layout_list.selected_layout_index),
@@ -190,30 +190,10 @@ impl NewSessionInfo {
.iter()
.enumerate()
.map(|(i, l)| (l.clone(), i == self.layout_list.selected_layout_index))
- .take(range_to_render.1)
- .skip(range_to_render.0)
+ .take(rtr.1)
+ .skip(rtr.0)
.collect()
}
- // TODO: merge with similar function in resurrectable_sessions
- fn range_to_render(
- &self,
- table_rows: usize,
- results_len: usize,
- selected_index: Option<usize>,
- ) -> (usize, usize) {
- if table_rows <= results_len {
- let row_count_to_render = table_rows.saturating_sub(1); // 1 for the title
- let first_row_index_to_render = selected_index
- .unwrap_or(0)
- .saturating_sub(row_count_to_render / 2);
- let last_row_index_to_render = first_row_index_to_render + row_count_to_render;
- (first_row_index_to_render, last_row_index_to_render)
- } else {
- let first_row_index_to_render = 0;
- let last_row_index_to_render = results_len;
- (first_row_index_to_render, last_row_index_to_render)
- }
- }
pub fn is_searching(&self) -> bool {
!self.layout_list.layout_search_term.is_empty()
}
@@ -224,27 +204,7 @@ impl NewSessionInfo {
self.layout_list.selected_layout_info()
}
fn update_layout_search_term(&mut self) {
- if self.layout_list.layout_search_term.is_empty() {
- self.layout_list.clear_selection();
- self.layout_list.layout_search_results = vec![];
- } else {
- let mut matches = vec![];
- let matcher = SkimMatcherV2::default().use_cache(true);
- for layout_info in &self.layout_list.layout_list {
- if let Some((score, indices)) =
- matcher.fuzzy_indices(&layout_info.name(), &self.layout_list.layout_search_term)
- {
- matches.push(LayoutSearchResult {
- layout_info: layout_info.clone(),
- score,
- indices,
- });
- }
- }
- matches.sort_by(|a, b| b.score.cmp(&a.score));
- self.layout_list.layout_search_results = matches;
- self.layout_list.clear_selection();
- }
+ self.layout_list.update_search_term();
}
fn move_selection_up(&mut self) {
self.layout_list.move_selection_up();
@@ -252,14 +212,17 @@ impl NewSessionInfo {
fn move_selection_down(&mut self) {
self.layout_list.move_selection_down();
}
+ pub fn get_layout_list_clone(&self) -> LayoutList {
+ self.layout_list.clone()
+ }
}
-#[derive(Default)]
+#[derive(Default, Clone)]
pub struct LayoutList {
- layout_list: Vec<LayoutInfo>,
- layout_search_results: Vec<LayoutSearchResult>,
- selected_layout_index: usize,
- layout_search_term: String,
+ pub layout_list: Vec<LayoutInfo>,
+ pub layout_search_results: Vec<LayoutSearchResult>,
+ pub selected_layout_index: usize,
+ pub layout_search_term: String,
}
impl LayoutList {
@@ -283,14 +246,14 @@ impl LayoutList {
pub fn clear_selection(&mut self) {
self.selected_layout_index = 0;
}
- fn max_index(&self) -> usize {
+ pub fn max_index(&self) -> usize {
if self.layout_search_term.is_empty() {
self.layout_list.len().saturating_sub(1)
} else {
self.layout_search_results.len().saturating_sub(1)
}
}
- fn move_selection_up(&mut self) {
+ pub fn move_selection_up(&mut self) {
let max_index = self.max_index();
if self.selected_layout_index > 0 {
self.selected_layout_index -= 1;
@@ -298,7 +261,7 @@ impl LayoutList {
self.selected_layout_index = max_index;
}
}
- fn move_selection_down(&mut self) {
+ pub fn move_selection_down(&mut self) {
let max_index = self.max_index();
if self.selected_layout_index < max_index {
self.selected_layout_index += 1;
@@ -306,6 +269,92 @@ impl LayoutList {
self.selected_layout_index = 0;
}
}
+ pub fn update_search_term(&mut self) {
+ if self.layout_search_term.is_empty() {
+ self.clear_selection();
+ self.layout_search_results = vec![];
+ } else {
+ let mut matches = vec![];
+ let matcher = SkimMatcherV2::default().use_cache(true);
+ for layout_info in &self.layout_list {
+ if let Some((score, indices)) =
+ matcher.fuzzy_indices(&layout_info.name(), &self.layout_search_term)
+ {
+ matches.push(LayoutSearchResult {
+ layout_info: layout_info.clone(),
+ score,
+ indices,
+ });
+ }
+ }
+ matches.sort_by(|a, b| b.score.cmp(&a.score));
+ self.layout_search_results = matches;
+ self.clear_selection();
+ }
+ }
+ pub fn layouts_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> {
+ if !self.layout_search_term.is_empty() {
+ self.layout_search_results_to_render(max_rows)
+ } else {
+ self.layout_list_to_render(max_rows)
+ }
+ }
+ fn layout_list_to_render(&self, max_rows: usize) -> Vec<(LayoutInfo, Vec<usize>, bool)> {
+ let range_to_render = range_to_render(
+ max_rows,
+ self.layout_list.len(),
+ Some(self.selected_layout_index),
+ );
+ self.layout_list
+ .iter()
+ .enumerate()
+ .map(|(i, l)| (l.clone(), vec![], i == self.selected_layout_index))
+ .take(range_to_render.1)
+ .skip(range_to_render.0)
+ .collect()
+ }
+ fn layout_search_results_to_render(
+ &self,
+ max_rows: usize,
+ ) -> Vec<(LayoutInfo, Vec<usize>, bool)> {
+ let range_to_render = range_to_render(
+ max_rows,
+ self.layout_search_results.len(),
+ Some(self.selected_layout_index),
+ );
+ self.layout_search_results
+ .iter()
+ .enumerate()
+ .map(|(i, l)| {
+ (
+ l.layout_info.clone(),
+ l.indices.clone(),
+ i == self.selected_layout_index,
+ )
+ })
+ .take(range_to_render.1)
+ .skip(range_to_render.0)
+ .collect()
+ }
+}
+
+pub fn range_to_render(
+ table_rows: usize,
+ results_len: usize,
+ selected_index: Option<usize>,
+) -> (usize, usize) {
+ if table_rows <= results_len {
+ let row_count_to_render = table_rows.saturating_sub(1); // 1 for the title
+ let first_row_index_to_render = selected_index
+ .unwrap_or(0)
+ .saturating_sub(row_count_to_render / 2);
+ let last_row_index_to_render = first_row_index_to_render + row_count_to_render;
+ (first_row_index_to_render, last_row_index_to_render)
+ } else {
+ let first_row_index_to_render = 0;
+ let last_row_index_to_render = results_len;
+ (first_row_index_to_render, last_row_index_to_render)
+ }
}
#[derive(Clone)]
@@ -314,3 +363,131 @@ pub struct LayoutSearchResult {
pub score: i64,
pub indices: Vec<usize>,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_layout(name: &str) -> LayoutInfo {
+ LayoutInfo::BuiltIn(name.to_string())
+ }
+
+ fn make_layout_list(names: &[&str]) -> LayoutList {
+ let mut ll = LayoutList::default();
+ ll.layout_list = names.iter().map(|n| make_layout(n)).collect();
+ ll
+ }
+
+ // ---------------------------------------------------------------
+ // Section 6: Layout List Navigation
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_6_1_layout_navigation_wraps_down() {
+ let mut ll = make_layout_list(&["a", "b", "c"]);
+ ll.selected_layout_index = 2;
+ ll.move_selection_down();
+ assert_eq!(ll.selected_layout_index, 0);
+ }
+
+ #[test]
+ fn test_6_2_layout_navigation_wraps_up() {
+ let mut ll = make_layout_list(&["a", "b", "c"]);
+ ll.selected_layout_index = 0;
+ ll.move_selection_up();
+ assert_eq!(ll.selected_layout_index, 2);
+ }
+
+ #[test]
+ fn test_6_3_layout_search_filters_results() {
+ let mut ll = make_layout_list(&["default", "compact", "development"]);
+ ll.layout_search_term = "dev".to_string();
+ ll.update_search_term();
+ // "development" should match
+ let matched_names: Vec<&str> = ll
+ .layout_search_results
+ .iter()
+ .map(|r| r.layout_info.name())
+ .collect();
+ assert!(matched_names.contains(&"development"));
+ // "default" and "compact" should not match "dev" well enough
+ // (though "default" starts with "de" so it might fuzzy-match — check)
+ // The key assertion is that "development" is present and is the best match.
+ // With SkimMatcherV2, "default" may also match "dev" (d, e from "default").
+ // So we just verify "development" is the top result.
+ assert_eq!(
+ ll.layout_search_results[0].layout_info.name(),
+ "development"
+ );
+ }
+
+ #[test]
+ fn test_6_4_selected_layout_info_returns_correct_layout() {
+ let ll = make_layout_list(&["a", "b", "c"]);
+ // selected_layout_index defaults to 0, set to 1
+ let mut ll = ll;
+ ll.selected_layout_index = 1;
+ let info = ll.selected_layout_info();
+ assert!(info.is_some());
+ assert_eq!(info.unwrap().name(), "b");
+ }
+
+ #[test]
+ fn test_6_5_layout_viewport_windowing() {
+ let mut ll = make_layout_list(&[
+ "layout-0", "layout-1", "layout-2", "layout-3", "layout-4", "layout-5", "layout-6",
+ "layout-7", "layout-8", "layout-9",
+ ]);
+ ll.selected_layout_index = 5;
+ let rendered = ll.layouts_to_render(5);
+ // Should return at most 5-1=4 entries (range_to_render subtracts 1 for title)
+ assert!(rendered.len() <= 5);
+ // The selected layout should be within the visible window
+ let selected_visible = rendered.iter().any(|(_, _, is_selected)| *is_selected);
+ assert!(selected_visible);
+ }
+
+ // ---------------------------------------------------------------
+ // Section 7: Viewport Scrolling (range_to_render tests)
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_7_1_all_results_fit_in_viewport() {
+ // When table_rows > results_len, all results are shown
+ let (start, end) = range_to_render(10, 5, None);
+ assert_eq!(start, 0);
+ assert_eq!(end, 5);
+ }
+
+ #[test]
+ fn test_7_2_viewport_scrolls_to_keep_selection_centered() {
+ // table_rows=6, results_len=20, selected=10
+ // row_count_to_render = 6-1 = 5, half = 2
+ // first = 10-2 = 8, last = 8+5 = 13
+ let (start, end) = range_to_render(6, 20, Some(10));
+ assert_eq!(start, 8);
+ assert_eq!(end, 13);
+ }
+
+ #[test]
+ fn test_7_3_viewport_clamps_at_bottom() {
+ // table_rows=6, results_len=20, selected=19
+ // row_count_to_render = 5, half = 2
+ // first = 19-2 = 17, last = 17+5 = 22 > 20
+ // Note: range_to_render does NOT clamp — it returns (17, 22)
+ // The actual clamping happens in the caller via .take().skip()
+ let (start, end) = range_to_render(6, 20, Some(19));
+ assert_eq!(start, 17);
+ assert_eq!(end, 22);
+ }
+
+ #[test]
+ fn test_7_4_viewport_clamps_at_top() {
+ // table_rows=6, results_len=20, selected=0
+ // row_count_to_render = 5, half = 2
+ // first = 0.saturating_sub(2) = 0, last = 0+5 = 5
+ let (start, end) = range_to_render(6, 20, Some(0));
+ assert_eq!(start, 0);
+ assert_eq!(end, 5);
+ }
+}
diff --git a/default-plugins/session-manager/src/single_screen.rs b/default-plugins/session-manager/src/single_screen.rs
new file mode 100644
index 00000000..fb7b0385
--- /dev/null
+++ b/default-plugins/session-manager/src/single_screen.rs
@@ -0,0 +1,939 @@
+use fuzzy_matcher::skim::SkimMatcherV2;
+use fuzzy_matcher::FuzzyMatcher;
+use std::path::PathBuf;
+use std::time::Duration;
+
+use crate::new_session_info::LayoutList;
+use crate::ui::components::UnifiedResultsRenderCache;
+use crate::ui::SessionUiInfo;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum SingleScreenMode {
+ SearchAndSelect,
+ SelectingLayout,
+}
+
+impl Default for SingleScreenMode {
+ fn default() -> Self {
+ SingleScreenMode::SearchAndSelect
+ }
+}
+
+#[derive(Debug)]
+pub enum UnifiedSearchResult {
+ ActiveSession {
+ score: i64,
+ indices: Vec<usize>,
+ session_name: String,
+ connected_users: usize,
+ tab_count: usize,
+ pane_count: usize,
+ is_current_session: bool,
+ creation_time: Duration,
+ },
+ ResurrectableSession {
+ score: i64,
+ indices: Vec<usize>,
+ session_name: String,
+ ctime: Duration,
+ },
+}
+
+impl UnifiedSearchResult {
+ pub fn session_name(&self) -> &str {
+ match self {
+ UnifiedSearchResult::ActiveSession { session_name, .. } => session_name.as_str(),
+ UnifiedSearchResult::ResurrectableSession { session_name, .. } => session_name.as_str(),
+ }
+ }
+ fn score(&self) -> i64 {
+ match self {
+ UnifiedSearchResult::ActiveSession { score, .. } => *score,
+ UnifiedSearchResult::ResurrectableSession { score, .. } => *score,
+ }
+ }
+ /// Ordering by type (active before resurrectable), then by creation time ascending
+ /// (smaller elapsed duration = more recently created = appears first).
+ fn cmp_by_type_then_recency(&self, other: &Self) -> std::cmp::Ordering {
+ match (self, other) {
+ (
+ UnifiedSearchResult::ActiveSession {
+ creation_time: ct_a,
+ ..
+ },
+ UnifiedSearchResult::ActiveSession {
+ creation_time: ct_b,
+ ..
+ },
+ ) => ct_a.cmp(ct_b),
+ (
+ UnifiedSearchResult::ResurrectableSession { ctime: ct_a, .. },
+ UnifiedSearchResult::ResurrectableSession { ctime: ct_b, .. },
+ ) => ct_a.cmp(ct_b),
+ (
+ UnifiedSearchResult::ActiveSession { .. },
+ UnifiedSearchResult::ResurrectableSession { .. },
+ ) => std::cmp::Ordering::Less,
+ (
+ UnifiedSearchResult::ResurrectableSession { .. },
+ UnifiedSearchResult::ActiveSession { .. },
+ ) => std::cmp::Ordering::Greater,
+ }
+ }
+}
+
+#[derive(Default)]
+pub struct SingleScreenState {
+ pub search_term: String,
+ pub unified_results: Vec<UnifiedSearchResult>,
+ pub selected_index: Option<usize>,
+ pub mode: SingleScreenMode,
+ pub layout_list: LayoutList,
+ pub new_session_folder: Option<PathBuf>,
+ pub is_welcome_screen: bool,
+ pub render_cache: UnifiedResultsRenderCache,
+ fuzzy_matcher: Option<SkimMatcherV2>,
+}
+
+impl std::fmt::Debug for SingleScreenState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("SingleScreenState")
+ .field("search_term", &self.search_term)
+ .field("selected_index", &self.selected_index)
+ .field("mode", &self.mode)
+ .field("result_count", &self.unified_results.len())
+ .finish()
+ }
+}
+
+impl SingleScreenState {
+ pub fn update_search_term(
+ &mut self,
+ active_sessions: &[SessionUiInfo],
+ resurrectable_sessions: &[(String, Duration)],
+ ) {
+ let previously_selected_name = self.previously_selected_name();
+ self.selected_index = None;
+
+ if self.search_term.is_empty() {
+ self.unified_results =
+ Self::collect_all_sessions(active_sessions, resurrectable_sessions);
+ self.unified_results
+ .sort_by(|a, b| a.cmp_by_type_then_recency(b));
+ } else {
+ self.unified_results =
+ self.collect_fuzzy_matched_sessions(active_sessions, resurrectable_sessions);
+ self.unified_results.sort_by(|a, b| {
+ let score_cmp = b.score().cmp(&a.score());
+ if score_cmp != std::cmp::Ordering::Equal {
+ return score_cmp;
+ }
+ a.cmp_by_type_then_recency(b)
+ });
+ }
+
+ self.render_cache.rebuild(&self.unified_results);
+ self.restore_selection(previously_selected_name);
+ }
+
+ fn previously_selected_name(&self) -> Option<String> {
+ self.selected_index
+ .and_then(|i| self.unified_results.get(i))
+ .map(|r| r.session_name().to_owned())
+ }
+
+ fn restore_selection(&mut self, previously_selected_name: Option<String>) {
+ if let Some(prev_name) = previously_selected_name {
+ self.selected_index = self
+ .unified_results
+ .iter()
+ .position(|r| r.session_name() == prev_name);
+ }
+ }
+
+ fn collect_all_sessions(
+ active_sessions: &[SessionUiInfo],
+ resurrectable_sessions: &[(String, Duration)],
+ ) -> Vec<UnifiedSearchResult> {
+ let mut results = Vec::new();
+ for session in active_sessions {
+ results.push(Self::active_session_to_result(session, 0, vec![]));
+ }
+ for (name, ctime) in resurrectable_sessions {
+ results.push(UnifiedSearchResult::ResurrectableSession {
+ score: 0,
+ indices: vec![],
+ session_name: name.clone(),
+ ctime: *ctime,
+ });
+ }
+ results
+ }
+
+ fn collect_fuzzy_matched_sessions(
+ &mut self,
+ active_sessions: &[SessionUiInfo],
+ resurrectable_sessions: &[(String, Duration)],
+ ) -> Vec<UnifiedSearchResult> {
+ let matcher = self
+ .fuzzy_matcher
+ .get_or_insert_with(|| SkimMatcherV2::default().use_cache(true));
+ let mut results = Vec::new();
+
+ for session in active_sessions {
+ if let Some((score, indices)) = matcher.fuzzy_indices(&session.name, &self.search_term)
+ {
+ results.push(Self::active_session_to_result(session, score, indices));
+ }
+ }
+ for (name, ctime) in resurrectable_sessions {
+ if let Some((score, indices)) = matcher.fuzzy_indices(name, &self.search_term) {
+ results.push(UnifiedSearchResult::ResurrectableSession {
+ score,
+ indices,
+ session_name: name.clone(),
+ ctime: *ctime,
+ });
+ }
+ }
+ results
+ }
+
+ fn active_session_to_result(
+ session: &SessionUiInfo,
+ score: i64,
+ indices: Vec<usize>,
+ ) -> UnifiedSearchResult {
+ UnifiedSearchResult::ActiveSession {
+ score,
+ indices,
+ session_name: session.name.clone(),
+ connected_users: session.connected_users,
+ tab_count: session.tabs.len(),
+ pane_count: session.tabs.iter().fold(0, |acc, t| acc + t.panes.len()),
+ is_current_session: session.is_current_session,
+ creation_time: session.creation_time,
+ }
+ }
+
+ fn is_current_session(&self, index: usize) -> bool {
+ matches!(
+ self.unified_results.get(index),
+ Some(UnifiedSearchResult::ActiveSession {
+ is_current_session: true,
+ ..
+ })
+ )
+ }
+
+ fn next_selectable_down(&self, from: usize) -> Option<usize> {
+ let len = self.unified_results.len();
+ for offset in 1..=len {
+ let candidate = (from + offset) % len;
+ if !self.is_current_session(candidate) {
+ return Some(candidate);
+ }
+ }
+ None
+ }
+
+ fn next_selectable_up(&self, from: usize) -> Option<usize> {
+ let len = self.unified_results.len();
+ for offset in 1..=len {
+ let candidate = (from + len - offset) % len;
+ if !self.is_current_session(candidate) {
+ return Some(candidate);
+ }
+ }
+ None
+ }
+
+ pub fn move_selection_down(&mut self) {
+ if self.unified_results.is_empty() {
+ return;
+ }
+ match self.selected_index {
+ None => {
+ self.selected_index =
+ self.next_selectable_down(self.unified_results.len().saturating_sub(1));
+ },
+ Some(i) => {
+ self.selected_index = self.next_selectable_down(i);
+ },
+ }
+ }
+
+ pub fn move_selection_up(&mut self) {
+ if self.unified_results.is_empty() {
+ return;
+ }
+ match self.selected_index {
+ None => {
+ self.selected_index = self.next_selectable_up(0);
+ },
+ Some(i) => {
+ self.selected_index = self.next_selectable_up(i);
+ },
+ }
+ }
+
+ pub fn tab_complete(
+ &mut self,
+ active_sessions: &[SessionUiInfo],
+ resurrectable_sessions: &[(String, Duration)],
+ ) {
+ let first_non_current = self.unified_results.iter().find(|r| {
+ !matches!(
+ r,
+ UnifiedSearchResult::ActiveSession {
+ is_current_session: true,
+ ..
+ }
+ )
+ });
+ if let Some(result) = first_non_current {
+ let name = result.session_name().to_owned();
+ self.search_term = name;
+ self.update_search_term(active_sessions, resurrectable_sessions);
+ }
+ }
+
+ pub fn get_selected_result(&self) -> Option<&UnifiedSearchResult> {
+ self.selected_index
+ .and_then(|i| self.unified_results.get(i))
+ }
+
+ pub fn transition_to_layout_selection(&mut self) {
+ self.mode = SingleScreenMode::SelectingLayout;
+ }
+
+ pub fn transition_to_search(&mut self) {
+ self.mode = SingleScreenMode::SearchAndSelect;
+ self.layout_list.layout_search_term.clear();
+ self.layout_list.clear_selection();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::Duration;
+
+ fn make_active_session(
+ name: &str,
+ tabs: usize,
+ panes_per_tab: usize,
+ connected: usize,
+ is_current: bool,
+ creation_secs: u64,
+ ) -> SessionUiInfo {
+ SessionUiInfo {
+ name: name.to_string(),
+ tabs: (0..tabs)
+ .map(|i| crate::ui::TabUiInfo {
+ name: format!("tab-{}", i),
+ panes: (0..panes_per_tab)
+ .map(|j| crate::ui::PaneUiInfo {
+ name: format!("pane-{}", j),
+ exit_code: None,
+ pane_id: j as u32,
+ is_plugin: false,
+ })
+ .collect(),
+ position: i,
+ })
+ .collect(),
+ connected_users: connected,
+ is_current_session: is_current,
+ creation_time: Duration::from_secs(creation_secs),
+ }
+ }
+
+ fn make_resurrectable(name: &str, ctime_secs: u64) -> (String, Duration) {
+ (name.to_string(), Duration::from_secs(ctime_secs))
+ }
+
+ // ---------------------------------------------------------------
+ // Section 1: Session Display and Sorting
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_1_1_active_sessions_sorted_by_recency() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let active = vec![
+ make_active_session("sess-300", 1, 1, 1, false, 300),
+ make_active_session("sess-100", 1, 1, 1, false, 100),
+ make_active_session("sess-200", 1, 1, 1, false, 200),
+ ];
+ state.update_search_term(&active, &[]);
+ assert_eq!(state.unified_results.len(), 3);
+ // Ascending creation_time: 100, 200, 300 (most recent first)
+ assert_eq!(state.unified_results[0].session_name(), "sess-100");
+ assert_eq!(state.unified_results[1].session_name(), "sess-200");
+ assert_eq!(state.unified_results[2].session_name(), "sess-300");
+ for r in &state.unified_results {
+ assert!(matches!(r, UnifiedSearchResult::ActiveSession { .. }));
+ }
+ }
+
+ #[test]
+ fn test_1_2_resurrectable_sessions_sorted_by_recency() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let resurrectable = vec![
+ make_resurrectable("res-300", 300),
+ make_resurrectable("res-100", 100),
+ make_resurrectable("res-200", 200),
+ ];
+ state.update_search_term(&[], &resurrectable);
+ assert_eq!(state.unified_results.len(), 3);
+ assert_eq!(state.unified_results[0].session_name(), "res-100");
+ assert_eq!(state.unified_results[1].session_name(), "res-200");
+ assert_eq!(state.unified_results[2].session_name(), "res-300");
+ for r in &state.unified_results {
+ assert!(matches!(
+ r,
+ UnifiedSearchResult::ResurrectableSession { .. }
+ ));
+ }
+ }
+
+ #[test]
+ fn test_1_3_mixed_active_and_resurrectable_active_first() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let active = vec![
+ make_active_session("active-200", 1, 1, 1, false, 200),
+ make_active_session("active-100", 1, 1, 1, false, 100),
+ ];
+ let resurrectable = vec![
+ make_resurrectable("res-50", 50),
+ make_resurrectable("res-150", 150),
+ ];
+ state.update_search_term(&active, &resurrectable);
+ assert_eq!(state.unified_results.len(), 4);
+ assert_eq!(state.unified_results[0].session_name(), "active-100");
+ assert_eq!(state.unified_results[1].session_name(), "active-200");
+ assert_eq!(state.unified_results[2].session_name(), "res-50");
+ assert_eq!(state.unified_results[3].session_name(), "res-150");
+ }
+
+ #[test]
+ fn test_1_4_current_session_included_in_results() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let active = vec![
+ make_active_session("current-sess", 1, 1, 1, true, 100),
+ make_active_session("other-sess", 1, 1, 1, false, 200),
+ ];
+ state.update_search_term(&active, &[]);
+ assert_eq!(state.unified_results.len(), 2);
+ // Current session should be present (filtering is done in renderer)
+ let has_current = state.unified_results.iter().any(|r| match r {
+ UnifiedSearchResult::ActiveSession {
+ is_current_session, ..
+ } => *is_current_session,
+ _ => false,
+ });
+ assert!(has_current);
+ }
+
+ #[test]
+ fn test_1_5_pane_counts_reflect_tab_structure() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let active = vec![make_active_session("sess", 3, 2, 1, false, 100)];
+ state.update_search_term(&active, &[]);
+ assert_eq!(state.unified_results.len(), 1);
+ match &state.unified_results[0] {
+ UnifiedSearchResult::ActiveSession {
+ tab_count,
+ pane_count,
+ ..
+ } => {
+ assert_eq!(*tab_count, 3);
+ assert_eq!(*pane_count, 6);
+ },
+ _ => panic!("Expected ActiveSession"),
+ }
+ }
+
+ #[test]
+ fn test_1_6_empty_sessions_list() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ state.update_search_term(&[], &[]);
+ assert!(state.unified_results.is_empty());
+ }
+
+ // ---------------------------------------------------------------
+ // Section 2: Search and Fuzzy Matching
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_2_1_empty_search_term_shows_all() {
+ let mut state = SingleScreenState::default();
+ state.search_term = String::new();
+ let active = vec![
+ make_active_session("a", 1, 1, 1, false, 100),
+ make_active_session("b", 1, 1, 1, false, 200),
+ make_active_session("c", 1, 1, 1, false, 300),
+ ];
+ let resurrectable = vec![make_resurrectable("d", 400), make_resurrectable("e", 500)];
+ state.update_search_term(&active, &resurrectable);
+ assert_eq!(state.unified_results.len(), 5);
+ }
+
+ #[test]
+ fn test_2_2_typing_filters_results() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("alpha", 1, 1, 1, false, 100),
+ make_active_session("beta", 1, 1, 1, false, 200),
+ make_active_session("gamma", 1, 1, 1, false, 300),
+ ];
+ state.search_term = "alp".to_string();
+ state.update_search_term(&active, &[]);
+ assert_eq!(state.unified_results.len(), 1);
+ assert_eq!(state.unified_results[0].session_name(), "alpha");
+ match &state.unified_results[0] {
+ UnifiedSearchResult::ActiveSession { score, indices, .. } => {
+ assert!(*score > 0);
+ assert!(!indices.is_empty());
+ },
+ _ => panic!("Expected ActiveSession"),
+ }
+ }
+
+ #[test]
+ fn test_2_3_fuzzy_matching_non_contiguous() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("my-project", 1, 1, 1, false, 100),
+ make_active_session("mapping", 1, 1, 1, false, 200),
+ ];
+ state.search_term = "mprj".to_string();
+ state.update_search_term(&active, &[]);
+ // At least "my-project" should match (m, p, r, j fuzzy)
+ let has_my_project = state
+ .unified_results
+ .iter()
+ .any(|r| r.session_name() == "my-project");
+ assert!(has_my_project);
+ }
+
+ #[test]
+ fn test_2_4_score_based_ordering() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("abc", 1, 1, 1, false, 100),
+ make_active_session("xabc", 1, 1, 1, false, 200),
+ make_active_session("xxabc", 1, 1, 1, false, 300),
+ ];
+ state.search_term = "abc".to_string();
+ state.update_search_term(&active, &[]);
+ assert!(!state.unified_results.is_empty());
+ // "abc" (exact match) should have the highest score and appear first
+ assert_eq!(state.unified_results[0].session_name(), "abc");
+ // Verify descending score order
+ let scores: Vec<i64> = state
+ .unified_results
+ .iter()
+ .map(|r| match r {
+ UnifiedSearchResult::ActiveSession { score, .. } => *score,
+ UnifiedSearchResult::ResurrectableSession { score, .. } => *score,
+ })
+ .collect();
+ for i in 0..scores.len().saturating_sub(1) {
+ assert!(scores[i] >= scores[i + 1]);
+ }
+ }
+
+ #[test]
+ fn test_2_5_tie_breaking_active_before_resurrectable() {
+ let mut state = SingleScreenState::default();
+ let active = vec![make_active_session("test-session", 1, 1, 1, false, 100)];
+ let resurrectable = vec![make_resurrectable("test-session", 100)];
+ state.search_term = "test".to_string();
+ state.update_search_term(&active, &resurrectable);
+ assert!(state.unified_results.len() >= 2);
+ // Find positions of active and resurrectable with the same name
+ let active_pos = state
+ .unified_results
+ .iter()
+ .position(|r| {
+ matches!(r, UnifiedSearchResult::ActiveSession { session_name, .. } if session_name == "test-session")
+ });
+ let resurrectable_pos = state
+ .unified_results
+ .iter()
+ .position(|r| {
+ matches!(r, UnifiedSearchResult::ResurrectableSession { session_name, .. } if session_name == "test-session")
+ });
+ assert!(active_pos.unwrap() < resurrectable_pos.unwrap());
+ }
+
+ #[test]
+ fn test_2_6_tie_breaking_more_recent_first_at_equal_score_and_type() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("test-a", 1, 1, 1, false, 100),
+ make_active_session("test-b", 1, 1, 1, false, 50),
+ ];
+ state.search_term = "test".to_string();
+ state.update_search_term(&active, &[]);
+ // At equal scores, ascending creation_time places test-b (50s) before test-a (100s)
+ let pos_a = state
+ .unified_results
+ .iter()
+ .position(|r| r.session_name() == "test-a");
+ let pos_b = state
+ .unified_results
+ .iter()
+ .position(|r| r.session_name() == "test-b");
+ if let (Some(a), Some(b)) = (pos_a, pos_b) {
+ // If scores are equal, test-b should come first (more recent)
+ let score_a = match &state.unified_results[a] {
+ UnifiedSearchResult::ActiveSession { score, .. } => *score,
+ _ => 0,
+ };
+ let score_b = match &state.unified_results[b] {
+ UnifiedSearchResult::ActiveSession { score, .. } => *score,
+ _ => 0,
+ };
+ if score_a == score_b {
+ assert!(
+ b < a,
+ "test-b (50s, more recent) should appear before test-a (100s)"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_2_7_search_term_matches_nothing() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("alpha", 1, 1, 1, false, 100),
+ make_active_session("beta", 1, 1, 1, false, 200),
+ ];
+ state.search_term = "zzz".to_string();
+ state.update_search_term(&active, &[]);
+ assert!(state.unified_results.is_empty());
+ }
+
+ #[test]
+ fn test_2_8_selection_preserved_across_search_updates() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("alpha", 1, 1, 1, false, 100),
+ make_active_session("beta", 1, 1, 1, false, 200),
+ make_active_session("gamma", 1, 1, 1, false, 300),
+ ];
+ // Step 1: empty search, populate results
+ state.search_term = String::new();
+ state.update_search_term(&active, &[]);
+ // Step 2: select "beta" (find its index)
+ let beta_idx = state
+ .unified_results
+ .iter()
+ .position(|r| r.session_name() == "beta")
+ .unwrap();
+ state.selected_index = Some(beta_idx);
+ // Step 3: search for "a" — beta does not match
+ state.search_term = "a".to_string();
+ state.update_search_term(&active, &[]);
+ // "beta" does not contain "a" in a way that fuzzy matches well.
+ // Check: if beta is not in results, selected_index should be None
+ let beta_still_present = state
+ .unified_results
+ .iter()
+ .any(|r| r.session_name() == "beta");
+ if !beta_still_present {
+ assert_eq!(state.selected_index, None);
+ } else {
+ // If beta still matches, selected_index should point to its new position
+ let new_beta_idx = state
+ .unified_results
+ .iter()
+ .position(|r| r.session_name() == "beta")
+ .unwrap();
+ assert_eq!(state.selected_index, Some(new_beta_idx));
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Section 3: Selection and Navigation
+ // ---------------------------------------------------------------
+
+ fn setup_results_with_current(state: &mut SingleScreenState, names: &[(&str, bool)]) {
+ // Directly populate unified_results for navigation tests
+ state.unified_results = names
+ .iter()
+ .enumerate()
+ .map(
+ |(i, (name, is_current))| UnifiedSearchResult::ActiveSession {
+ score: 0,
+ indices: vec![],
+ session_name: name.to_string(),
+ connected_users: 1,
+ tab_count: 1,
+ pane_count: 1,
+ is_current_session: *is_current,
+ creation_time: Duration::from_secs(i as u64 * 100),
+ },
+ )
+ .collect();
+ }
+
+ #[test]
+ fn test_3_1_down_from_none_selects_first_non_current() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[("current", true), ("other-a", false), ("other-b", false)],
+ );
+ state.selected_index = None;
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(1));
+ }
+
+ #[test]
+ fn test_3_2_down_from_last_wraps_to_first_non_current() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[("other-a", false), ("current", true), ("other-b", false)],
+ );
+ state.selected_index = Some(2); // other-b, the last
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(0)); // wraps, skips current at 1
+ }
+
+ #[test]
+ fn test_3_3_up_from_none_selects_last_non_current() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[("other-a", false), ("current", true), ("other-b", false)],
+ );
+ state.selected_index = None;
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(2)); // other-b
+ }
+
+ #[test]
+ fn test_3_4_up_from_first_wraps_to_last_non_current() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[("other-a", false), ("current", true), ("other-b", false)],
+ );
+ state.selected_index = Some(0);
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(2)); // wraps, skips current
+ }
+
+ #[test]
+ fn test_3_5_navigation_with_only_current_session() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(&mut state, &[("current", true)]);
+ state.selected_index = None;
+ state.move_selection_down();
+ assert_eq!(state.selected_index, None);
+ state.move_selection_up();
+ assert_eq!(state.selected_index, None);
+ }
+
+ #[test]
+ fn test_3_6_navigation_with_empty_results() {
+ let mut state = SingleScreenState::default();
+ state.unified_results = vec![];
+ state.selected_index = None;
+ state.move_selection_down();
+ assert_eq!(state.selected_index, None);
+ state.move_selection_up();
+ assert_eq!(state.selected_index, None);
+ }
+
+ #[test]
+ fn test_3_7_sequential_down_visits_all_non_current() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[
+ ("a", false),
+ ("current", true),
+ ("b", false),
+ ("c", false),
+ ("d", false),
+ ],
+ );
+ state.selected_index = None;
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(0)); // "a"
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(2)); // "b", skip current at 1
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(3)); // "c"
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(4)); // "d"
+ state.move_selection_down();
+ assert_eq!(state.selected_index, Some(0)); // "a", wrapped
+ }
+
+ #[test]
+ fn test_3_8_sequential_up_visits_all_non_current_in_reverse() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(
+ &mut state,
+ &[
+ ("a", false),
+ ("current", true),
+ ("b", false),
+ ("c", false),
+ ("d", false),
+ ],
+ );
+ state.selected_index = None;
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(4)); // "d"
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(3)); // "c"
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(2)); // "b"
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(0)); // "a", skip current at 1
+ state.move_selection_up();
+ assert_eq!(state.selected_index, Some(4)); // "d", wrapped
+ }
+
+ #[test]
+ fn test_3_9_get_selected_result_returns_correct_result() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(&mut state, &[("a", false), ("b", false), ("c", false)]);
+ state.selected_index = Some(1);
+ let result = state.get_selected_result();
+ assert!(result.is_some());
+ assert_eq!(result.unwrap().session_name(), "b");
+ }
+
+ #[test]
+ fn test_3_10_get_selected_result_returns_none_when_nothing_selected() {
+ let mut state = SingleScreenState::default();
+ setup_results_with_current(&mut state, &[("a", false), ("b", false), ("c", false)]);
+ state.selected_index = None;
+ assert!(state.get_selected_result().is_none());
+ }
+
+ // ---------------------------------------------------------------
+ // Section 4: Tab Completion
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_4_1_basic_tab_completion() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("mine", 1, 1, 1, true, 100),
+ make_active_session("alpha", 1, 1, 1, false, 200),
+ make_active_session("beta", 1, 1, 1, false, 300),
+ ];
+ state.search_term = String::new();
+ state.update_search_term(&active, &[]);
+ state.tab_complete(&active, &[]);
+ // First non-current in sorted results should be "alpha" or whichever is first
+ // With empty search, sorted by creation_time ascending: mine(100), alpha(200), beta(300)
+ // First non-current is "alpha"
+ assert_eq!(state.search_term, "alpha");
+ }
+
+ #[test]
+ fn test_4_2_tab_completion_only_current_session_noop() {
+ let mut state = SingleScreenState::default();
+ let active = vec![make_active_session("mine", 1, 1, 1, true, 100)];
+ state.search_term = String::new();
+ state.update_search_term(&active, &[]);
+ state.tab_complete(&active, &[]);
+ assert_eq!(state.search_term, "");
+ }
+
+ #[test]
+ fn test_4_3_tab_completion_picks_resurrectable_if_no_non_current_active() {
+ let mut state = SingleScreenState::default();
+ let active = vec![make_active_session("mine", 1, 1, 1, true, 100)];
+ let resurrectable = vec![make_resurrectable("old-session", 100)];
+ state.search_term = String::new();
+ state.update_search_term(&active, &resurrectable);
+ state.tab_complete(&active, &resurrectable);
+ assert_eq!(state.search_term, "old-session");
+ }
+
+ #[test]
+ fn test_4_4_tab_completion_with_partial_search_term() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("mine", 1, 1, 1, true, 100),
+ make_active_session("alpha", 1, 1, 1, false, 200),
+ make_active_session("beta", 1, 1, 1, false, 300),
+ ];
+ let resurrectable: Vec<(String, Duration)> = vec![];
+ state.search_term = "be".to_string();
+ state.update_search_term(&active, &resurrectable);
+ state.tab_complete(&active, &resurrectable);
+ assert_eq!(state.search_term, "beta");
+ }
+
+ #[test]
+ fn test_4_5_tab_completion_with_empty_results_noop() {
+ let mut state = SingleScreenState::default();
+ let active = vec![
+ make_active_session("alpha", 1, 1, 1, false, 100),
+ make_active_session("beta", 1, 1, 1, false, 200),
+ ];
+ state.search_term = "zzz".to_string();
+ state.update_search_term(&active, &[]);
+ // Results should be empty — no matches
+ assert!(state.unified_results.is_empty());
+ state.tab_complete(&active, &[]);
+ assert_eq!(state.search_term, "zzz"); // unchanged
+ }
+
+ // ---------------------------------------------------------------
+ // Section 5: Mode Transitions
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_5_1_transition_to_layout_selection() {
+ let mut state = SingleScreenState::default();
+ state.mode = SingleScreenMode::SearchAndSelect;
+ state.search_term = "my-session".to_string();
+ state.transition_to_layout_selection();
+ assert_eq!(state.mode, SingleScreenMode::SelectingLayout);
+ assert_eq!(state.search_term, "my-session"); // preserved
+ }
+
+ #[test]
+ fn test_5_2_transition_back_to_search() {
+ let mut state = SingleScreenState::default();
+ state.mode = SingleScreenMode::SelectingLayout;
+ state.layout_list.layout_search_term = "some-layout".to_string();
+ state.layout_list.selected_layout_index = 3;
+ state.transition_to_search();
+ assert_eq!(state.mode, SingleScreenMode::SearchAndSelect);
+ assert_eq!(state.layout_list.layout_search_term, "");
+ assert_eq!(state.layout_list.selected_layout_index, 0);
+ }
+
+ #[test]
+ fn test_5_3_round_trip_transition_preserves_search_term() {
+ let mut state = SingleScreenState::default();
+ state.search_term = "my-new-session".to_string();
+ state.mode = SingleScreenMode::SearchAndSelect;
+ state.transition_to_layout_selection();
+ assert_eq!(state.mode, SingleScreenMode::SelectingLayout);
+ state.transition_to_search();
+ assert_eq!(state.search_term, "my-new-session");
+ assert_eq!(state.mode, SingleScreenMode::SearchAndSelect);
+ }
+}
diff --git a/default-plugins/session-manager/src/ui/components.rs b/default-plugins/session-manager/src/ui/components.rs
index 8310d2d3..b698e6ca 100644
--- a/default-plugins/session-manager/src/ui/components.rs
+++ b/default-plugins/session-manager/src/ui/components.rs
@@ -5,9 +5,241 @@ use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
+use crate::single_screen::UnifiedSearchResult;
use crate::ui::{PaneUiInfo, SessionUiInfo, TabUiInfo};
use crate::{ActiveScreen, NewSessionInfo};
+// ---------------------------------------------------------------
+// Render cache for unified results
+// ---------------------------------------------------------------
+
+/// Pre-computed data for a single visible row, independent of selection state.
+#[derive(Clone)]
+pub struct CachedRowData {
+ pub session_name: String,
+ pub indices: Vec<usize>,
+ pub original_index: usize,
+ pub kind: CachedRowKind,
+ // Pre-formatted strings (computed once, reused across renders)
+ pub full_details: String,
+ pub abbr_details: String,
+ pub full_tag: &'static str,
+ pub abbr_tag: &'static str,
+ // Pre-computed widths
+ pub name_width: usize,
+ pub full_details_width: usize,
+ pub abbr_details_width: usize,
+ pub full_tag_width: usize,
+ // Color range data for details cell
+ pub details_color_ranges: DetailsColorRanges,
+ pub abbr_details_color_ranges: DetailsColorRanges,
+}
+
+#[derive(Clone)]
+pub enum CachedRowKind {
+ Active,
+ Resurrectable,
+}
+
+/// Byte-offset ranges for coloring details cells.
+#[derive(Clone, Default)]
+pub struct DetailsColorRanges {
+ pub ranges: Vec<(usize, std::ops::Range<usize>)>, // (color_index, range)
+}
+
+/// Cached intermediate representation of the unified results table.
+///
+/// Stored on `SingleScreenState` and rebuilt only when `unified_results`
+/// changes (via `update_search_term` / `SessionUpdate`). Selection changes
+/// and timer re-renders reuse the cached data.
+#[derive(Default)]
+pub struct UnifiedResultsRenderCache {
+ /// Filtered rows (current session excluded), with pre-formatted strings.
+ pub rows: Vec<CachedRowData>,
+ /// Max column widths across all cached rows.
+ pub full_name_width: usize,
+ pub full_details_width: usize,
+ pub abbr_details_width: usize,
+ pub full_tag_width: usize,
+}
+
+impl UnifiedResultsRenderCache {
+ /// Rebuild the cache from the current `unified_results`.
+ pub fn rebuild(&mut self, results: &[UnifiedSearchResult]) {
+ self.rows.clear();
+ self.full_name_width = 0;
+ self.full_details_width = 0;
+ self.abbr_details_width = 0;
+ self.full_tag_width = 0;
+
+ for (orig_i, result) in results.iter().enumerate() {
+ let is_current = matches!(
+ result,
+ UnifiedSearchResult::ActiveSession {
+ is_current_session: true,
+ ..
+ }
+ );
+ if is_current {
+ continue;
+ }
+
+ let row = match result {
+ UnifiedSearchResult::ActiveSession {
+ indices,
+ session_name,
+ connected_users,
+ tab_count,
+ pane_count,
+ ..
+ } => {
+ let client_word = if *connected_users == 1 {
+ "client"
+ } else {
+ "clients"
+ };
+
+ let tab_str = format!("{}", tab_count);
+ let pane_str = format!("{}", pane_count);
+ let conn_str = format!("{}", connected_users);
+
+ // Full details
+ let full_details = format!(
+ "{} tabs, {} panes, {} {}",
+ tab_str, pane_str, conn_str, client_word
+ );
+ let full_details_ranges = {
+ let tab_end = tab_str.len();
+ let pane_offset = tab_str.len() + " tabs, ".len();
+ let pane_end = pane_offset + pane_str.len();
+ let conn_offset = pane_end + " panes, ".len();
+ let conn_end = conn_offset + conn_str.len();
+ DetailsColorRanges {
+ ranges: vec![
+ (1, 0..tab_end),
+ (2, pane_offset..pane_end),
+ (2, conn_offset..conn_end),
+ ],
+ }
+ };
+
+ // Abbreviated details
+ let abbr_details = format!("{}t, {}p, {}c", tab_str, pane_str, conn_str);
+ let abbr_details_ranges = {
+ let tab_end = tab_str.len();
+ let pane_offset = tab_str.len() + "t, ".len();
+ let pane_end = pane_offset + pane_str.len();
+ let conn_offset = pane_end + "p, ".len();
+ let conn_end = conn_offset + conn_str.len();
+ DetailsColorRanges {
+ ranges: vec![
+ (1, 0..tab_end),
+ (2, pane_offset..pane_end),
+ (2, conn_offset..conn_end),
+ ],
+ }
+ };
+
+ let name_width = session_name.width();
+ let full_details_width = full_details.width();
+ let abbr_details_width = abbr_details.width();
+
+ CachedRowData {
+ session_name: session_name.clone(),
+ indices: indices.clone(),
+ original_index: orig_i,
+ kind: CachedRowKind::Active,
+ full_details,
+ abbr_details,
+ full_tag: "[ATTACH]",
+ abbr_tag: "[A]",
+ name_width,
+ full_details_width,
+ abbr_details_width,
+ full_tag_width: "[ATTACH]".len(),
+ details_color_ranges: full_details_ranges,
+ abbr_details_color_ranges: abbr_details_ranges,
+ }
+ },
+ UnifiedSearchResult::ResurrectableSession {
+ indices,
+ session_name,
+ ctime,
+ ..
+ } => {
+ let duration = humantime::format_duration(*ctime).to_string();
+ let mut formatted_duration = String::new();
+ for part in duration.split_whitespace() {
+ if !part.ends_with('s') {
+ if !formatted_duration.is_empty() {
+ formatted_duration.push(' ');
+ }
+ formatted_duration.push_str(part);
+ }
+ }
+ if formatted_duration.is_empty() {
+ formatted_duration.push_str("<1m");
+ }
+
+ let full_details = format!("Created {} ago", formatted_duration);
+ let full_details_ranges = {
+ let created_len = "Created ".len();
+ let duration_end = created_len + formatted_duration.len();
+ DetailsColorRanges {
+ ranges: vec![(2, created_len..duration_end)],
+ }
+ };
+
+ let abbr_details = format!("{} ago", formatted_duration);
+ let abbr_details_ranges = {
+ let duration_end = formatted_duration.len();
+ DetailsColorRanges {
+ ranges: vec![(2, 0..duration_end)],
+ }
+ };
+
+ let name_width = session_name.width();
+ let full_details_width = full_details.width();
+ let abbr_details_width = abbr_details.width();
+
+ CachedRowData {
+ session_name: session_name.clone(),
+ indices: indices.clone(),
+ original_index: orig_i,
+ kind: CachedRowKind::Resurrectable,
+ full_details,
+ abbr_details,
+ full_tag: "[RESURRECT]",
+ abbr_tag: "[R]",
+ name_width,
+ full_details_width,
+ abbr_details_width,
+ full_tag_width: "[RESURRECT]".len(),
+ details_color_ranges: full_details_ranges,
+ abbr_details_color_ranges: abbr_details_ranges,
+ }
+ },
+ };
+
+ // Update max widths
+ if row.name_width > self.full_name_width {
+ self.full_name_width = row.name_width;
+ }
+ if row.full_details_width > self.full_details_width {
+ self.full_details_width = row.full_details_width;
+ }
+ if row.abbr_details_width > self.abbr_details_width {
+ self.abbr_details_width = row.abbr_details_width;
+ }
+ if row.full_tag_width > self.full_tag_width {
+ self.full_tag_width = row.full_tag_width;
+ }
+
+ self.rows.push(row);
+ }
+ }
+}
+
#[derive(Debug)]
pub struct ListItem {
pub name: String,
@@ -210,6 +442,21 @@ impl Default for SpanStyle {
}
}
+fn truncate_to_width(text: &str, max_width: usize) -> String {
+ use unicode_width::UnicodeWidthChar;
+ let mut result = String::new();
+ let mut current_width = 0;
+ for ch in text.chars() {
+ let ch_width = ch.width().unwrap_or(0);
+ if current_width + ch_width > max_width {
+ break;
+ }
+ result.push(ch);
+ current_width += ch_width;
+ }
+ result
+}
+
#[derive(Debug, Default)]
pub struct TruncatableUiSpan {
text: String,
@@ -536,6 +783,272 @@ pub fn render_prompt(search_term: &str, colors: Colors, x: usize, y: usize) {
);
}
+pub fn render_single_screen_prompt(
+ search_term: &str,
+ enter_action: Option<&str>,
+ colors: Colors,
+ x: usize,
+ y: usize,
+) {
+ let prompt = colors.session_name_prompt("Session:");
+ let search_term_display = colors.bold(&format!("{}_", search_term));
+ let enter_hint = match enter_action {
+ Some(action) => {
+ let enter_key = colors.shortcuts("<ENTER>");
+ format!(" {} - {}", enter_key, action)
+ },
+ None => String::new(),
+ };
+ println!(
+ "\u{1b}[m\u{1b}[{};{}H\u{1b}[0m{} {}{}\n",
+ y + 2,
+ x + 1,
+ prompt,
+ search_term_display,
+ enter_hint,
+ );
+}
+
+pub fn render_unified_results(
+ cache: &UnifiedResultsRenderCache,
+ selected_index: Option<usize>,
+ max_rows: usize,
+ max_cols: usize,
+ _colors: Colors,
+ x: usize,
+ y: usize,
+) {
+ if cache.rows.is_empty() {
+ return;
+ }
+
+ // Map selected_index (from original results) to filtered/cached position
+ let filtered_selected =
+ selected_index.and_then(|sel| cache.rows.iter().position(|r| r.original_index == sel));
+
+ // Calculate viewport range over the cached (already filtered) list
+ let total = cache.rows.len();
+ let data_rows = max_rows.saturating_sub(1); // 1 for the empty header
+ let (start, end) = if data_rows >= total {
+ (0, total)
+ } else {
+ let anchor = filtered_selected.unwrap_or(0);
+ let half = data_rows / 2;
+ let mut s = anchor.saturating_sub(half);
+ let mut e = s + data_rows;
+ if e > total {
+ e = total;
+ s = total.saturating_sub(data_rows);
+ }
+ (s, e)
+ };
+
+ // Hidden-item counts (single pass over two slices)
+ let (above_active, above_resurrectable) = count_by_kind(&cache.rows[..start]);
+ let (below_active, below_resurrectable) = count_by_kind(&cache.rows[end..]);
+
+ let has_hidden_above = above_active > 0 || above_resurrectable > 0;
+ let has_hidden_below = below_active > 0 || below_resurrectable > 0;
+ let has_hidden = has_hidden_above || has_hidden_below;
+
+ // 4th column content strings
+ let tab_header_full = "<TAB> Complete";
+ let tab_header_short = "<TAB>";
+
+ let above_summary_full = if has_hidden_above {
+ format!(
+ "[+{} Active] [+{} Exited]",
+ above_active, above_resurrectable
+ )
+ } else {
+ String::new()
+ };
+ let above_summary_short = if has_hidden_above {
+ format!("[+{}] [+{}]", above_active, above_resurrectable)
+ } else {
+ String::new()
+ };
+ let below_summary_full = if has_hidden_below {
+ format!(
+ "[+{} Active] [+{} Exited]",
+ below_active, below_resurrectable
+ )
+ } else {
+ String::new()
+ };
+ let below_summary_short = if has_hidden_below {
+ format!("[+{}] [+{}]", below_active, below_resurrectable)
+ } else {
+ String::new()
+ };
+
+ let max_summary_full_width = std::cmp::max(
+ if has_hidden_above {
+ above_summary_full.width()
+ } else {
+ 0
+ },
+ if has_hidden_below {
+ below_summary_full.width()
+ } else {
+ 0
+ },
+ );
+ let max_summary_short_width = std::cmp::max(
+ if has_hidden_above {
+ above_summary_short.width()
+ } else {
+ 0
+ },
+ if has_hidden_below {
+ below_summary_short.width()
+ } else {
+ 0
+ },
+ );
+
+ let full_fourth_col_width = std::cmp::max(
+ tab_header_full.width(),
+ if has_hidden {
+ max_summary_full_width
+ } else {
+ 1
+ },
+ );
+ let short_fourth_col_width = std::cmp::max(
+ tab_header_short.width(),
+ if has_hidden {
+ max_summary_short_width
+ } else {
+ 1
+ },
+ );
+
+ // Use pre-computed widths from cache — no format!() allocations needed
+ let (abbreviate_details, abbreviate_tags, abbreviate_fourth_col, name_max_width) =
+ compute_reduction_tier(
+ cache.full_name_width,
+ cache.full_details_width,
+ cache.full_tag_width,
+ full_fourth_col_width,
+ cache.abbr_details_width,
+ short_fourth_col_width,
+ max_cols,
+ );
+
+ // Build table from cached data
+ let mut table = Table::new();
+
+ // Empty header row
+ table = table.add_styled_row(vec![
+ Text::new(" "),
+ Text::new(" "),
+ Text::new(" "),
+ Text::new(" "),
+ ]);
+
+ let visible_count = end - start;
+ for (row_index, row) in cache.rows[start..end].iter().enumerate() {
+ let is_selected = filtered_selected == Some(start + row_index);
+
+ // Name cell — use cached string, only truncate if needed
+ let display_name = match name_max_width {
+ Some(max_w) => truncate_to_width(&row.session_name, max_w),
+ None => row.session_name.clone(),
+ };
+
+ let display_indices: Vec<usize> = row
+ .indices
+ .iter()
+ .filter(|&&i| i < display_name.chars().count())
+ .cloned()
+ .collect();
+
+ let mut name_cell = Text::new(display_name).color_range(1, ..);
+ if !display_indices.is_empty() {
+ name_cell = name_cell.color_indices(3, display_indices);
+ }
+
+ // Details and tag cells — use cached pre-formatted strings
+ let color_ranges = if abbreviate_details {
+ &row.abbr_details_color_ranges
+ } else {
+ &row.details_color_ranges
+ };
+ let details_text = if abbreviate_details {
+ &row.abbr_details
+ } else {
+ &row.full_details
+ };
+ let mut details_cell = Text::new(details_text);
+ for (color_idx, range) in &color_ranges.ranges {
+ details_cell = details_cell.color_range(*color_idx, range.clone());
+ }
+
+ let tag_text = if abbreviate_tags {
+ row.abbr_tag
+ } else {
+ row.full_tag
+ };
+ let tag_cell = Text::new(tag_text).color_range(0, ..);
+
+ // 4th column
+ let fourth_cell = if row_index == 0 && has_hidden_above {
+ let (summary_text, active_count, resurrectable_count) = if abbreviate_fourth_col {
+ (&above_summary_short, above_active, above_resurrectable)
+ } else {
+ (&above_summary_full, above_active, above_resurrectable)
+ };
+ Text::new(summary_text)
+ .color_substring(2, &format!("+{}", active_count))
+ .color_substring(2, &format!("+{}", resurrectable_count))
+ } else if row_index == 0 && selected_index.is_none() {
+ let tab_hint_text = if abbreviate_fourth_col {
+ tab_header_short
+ } else {
+ tab_header_full
+ };
+ Text::new(tab_hint_text).color_substring(3, "<TAB>")
+ } else if row_index == visible_count - 1 && has_hidden_below {
+ let (summary_text, active_count, resurrectable_count) = if abbreviate_fourth_col {
+ (&below_summary_short, below_active, below_resurrectable)
+ } else {
+ (&below_summary_full, below_active, below_resurrectable)
+ };
+ Text::new(summary_text)
+ .color_substring(2, &format!("+{}", active_count))
+ .color_substring(2, &format!("+{}", resurrectable_count))
+ } else {
+ Text::new(" ")
+ };
+
+ if is_selected {
+ table = table.add_styled_row(vec![
+ name_cell.selected(),
+ details_cell.selected(),
+ tag_cell.selected(),
+ fourth_cell,
+ ]);
+ } else {
+ table = table.add_styled_row(vec![name_cell, details_cell, tag_cell, fourth_cell]);
+ }
+ }
+
+ print_table_with_coordinates(table, x, y, Some(max_cols), Some(max_rows));
+}
+
+fn count_by_kind(rows: &[CachedRowData]) -> (usize, usize) {
+ let mut active = 0;
+ let mut resurrectable = 0;
+ for row in rows {
+ match row.kind {
+ CachedRowKind::Active => active += 1,
+ CachedRowKind::Resurrectable => resurrectable += 1,
+ }
+ }
+ (active, resurrectable)
+}
+
pub fn render_screen_toggle(
active_screen: ActiveScreen,
x: usize,
@@ -569,6 +1082,10 @@ pub fn render_screen_toggle(
ActiveScreen::ResurrectSession => {
exited_sessions_text = exited_sessions_text.selected();
},
+ ActiveScreen::SingleScreen => {
+ // SingleScreen does not use the tab toggle; this arm exists for exhaustiveness
+ return;
+ },
}
let bg_color = match background {
PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b),
@@ -988,6 +1505,35 @@ pub fn render_controls_line(
false
}
},
+ ActiveScreen::SingleScreen => {
+ let rename = colors.shortcuts("<Ctrl r>");
+ let rename_text = colors.bold("Rename");
+ let disconnect = colors.shortcuts("<Ctrl x>");
+ let disconnect_full_text = colors.bold("Disconnect others");
+ let disconnect_short_text = colors.bold("Disconnect");
+ let kill = colors.shortcuts("<Del>");
+ let kill_text = colors.bold("Kill/Delete");
+
+ // Full: "Help: <Ctrl r> - Rename, <Ctrl x> - Disconnect others, <Del> - Kill/Delete" = 76 chars
+ if max_cols > 76 {
+ print!(
+ "\u{1b}[m\u{1b}[{y};{x}HHelp: {rename} - {rename_text}, {disconnect} - {disconnect_full_text}, {kill} - {kill_text}"
+ );
+ true
+ // Medium: "Help: <Ctrl r> - Rename, <Ctrl x> - Disconnect, <Del> - Kill/Delete" = 69 chars
+ } else if max_cols > 69 {
+ print!(
+ "\u{1b}[m\u{1b}[{y};{x}HHelp: {rename} - {rename_text}, {disconnect} - {disconnect_short_text}, {kill} - {kill_text}"
+ );
+ true
+ // Compact: "<Ctrl r>/<Ctrl x>/<Del>" = 23 chars
+ } else if max_cols >= 23 {
+ print!("\u{1b}[m\u{1b}[{y};{x}H{rename}/{disconnect}/{kill}");
+ false
+ } else {
+ false
+ }
+ },
};
if shows_help_prefix {
@@ -1148,6 +1694,62 @@ impl Colors {
}
}
+/// Computes the width reduction tier for the unified results table.
+///
+/// Returns `(abbreviate_details, abbreviate_tags, abbreviate_fourth_col, name_max_width)`.
+/// - Tier 0: everything fits at full size
+/// - Tier 1: abbreviate details only
+/// - Tier 2: abbreviate details + tags
+/// - Tier 3: abbreviate details + tags + 4th column
+/// - Tier 4: abbreviate all + truncate session names
+pub fn compute_reduction_tier(
+ full_name_width: usize,
+ full_details_width: usize,
+ full_tag_width: usize,
+ full_fourth_col_width: usize,
+ abbr_details_width: usize,
+ short_fourth_col_width: usize,
+ max_cols: usize,
+) -> (bool, bool, bool, Option<usize>) {
+ let full_total =
+ full_name_width + full_details_width + full_tag_width + full_fourth_col_width + 4;
+ if full_total <= max_cols {
+ // Everything fits at full size
+ (false, false, false, None)
+ } else {
+ // Reduction 1: abbreviate details
+ let total_after_details =
+ full_name_width + abbr_details_width + full_tag_width + full_fourth_col_width + 4;
+ if total_after_details <= max_cols {
+ (true, false, false, None)
+ } else {
+ // Reduction 2: abbreviate tags
+ let abbr_tag_width = 3; // "[A]" or "[R]"
+ let total_after_tags =
+ full_name_width + abbr_details_width + abbr_tag_width + full_fourth_col_width + 4;
+ if total_after_tags <= max_cols {
+ (true, true, false, None)
+ } else {
+ // Reduction 3: abbreviate 4th column
+ let total_after_fourth = full_name_width
+ + abbr_details_width
+ + abbr_tag_width
+ + short_fourth_col_width
+ + 4;
+ if total_after_fourth <= max_cols {
+ (true, true, true, None)
+ } else {
+ // Reduction 4: truncate session names
+ let available_for_name = max_cols.saturating_sub(
+ abbr_details_width + abbr_tag_width + short_fourth_col_width + 4,
+ );
+ (true, true, true, Some(available_for_name))
+ }
+ }
+ }
+ }
+}
+
fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String {
let mut truncated = String::new();
let component_count = path.iter().count();
@@ -1169,3 +1771,77 @@ fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String {
}
truncated
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ---------------------------------------------------------------
+ // Section 8: Width Responsiveness (compute_reduction_tier)
+ // ---------------------------------------------------------------
+
+ #[test]
+ fn test_8_1_tier_0_full_width_everything_fits() {
+ // full_total = 20 + 30 + 11 + 15 + 4 = 80, max_cols = 120
+ let (abbr_details, abbr_tags, abbr_fourth, name_max) =
+ compute_reduction_tier(20, 30, 11, 15, 10, 5, 120);
+ assert_eq!(
+ (abbr_details, abbr_tags, abbr_fourth, name_max),
+ (false, false, false, None)
+ );
+ }
+
+ #[test]
+ fn test_8_2_tier_1_abbreviate_details_only() {
+ // full_total = 20 + 30 + 11 + 15 + 4 = 80
+ // after abbr details: 20 + 10 + 11 + 15 + 4 = 60
+ // max_cols = 70: full doesn't fit (80 > 70), but abbr details fits (60 <= 70)
+ let (abbr_details, abbr_tags, abbr_fourth, name_max) =
+ compute_reduction_tier(20, 30, 11, 15, 10, 5, 70);
+ assert_eq!(
+ (abbr_details, abbr_tags, abbr_fourth, name_max),
+ (true, false, false, None)
+ );
+ }
+
+ #[test]
+ fn test_8_3_tier_2_abbreviate_details_and_tags() {
+ // full_total = 20 + 30 + 11 + 15 + 4 = 80
+ // after abbr details: 20 + 10 + 11 + 15 + 4 = 60
+ // after abbr tags: 20 + 10 + 3 + 15 + 4 = 52
+ // max_cols = 55: abbr details doesn't fit (60 > 55), abbr tags fits (52 <= 55)
+ let (abbr_details, abbr_tags, abbr_fourth, name_max) =
+ compute_reduction_tier(20, 30, 11, 15, 10, 5, 55);
+ assert_eq!(
+ (abbr_details, abbr_tags, abbr_fourth, name_max),
+ (true, true, false, None)
+ );
+ }
+
+ #[test]
+ fn test_8_4_tier_3_abbreviate_details_tags_and_fourth_col() {
+ // full_total = 20 + 30 + 11 + 15 + 4 = 80
+ // after abbr details: 20 + 10 + 11 + 15 + 4 = 60
+ // after abbr tags: 20 + 10 + 3 + 15 + 4 = 52
+ // after abbr fourth: 20 + 10 + 3 + 5 + 4 = 42
+ // max_cols = 45: abbr tags doesn't fit (52 > 45), abbr fourth fits (42 <= 45)
+ let (abbr_details, abbr_tags, abbr_fourth, name_max) =
+ compute_reduction_tier(20, 30, 11, 15, 10, 5, 45);
+ assert_eq!(
+ (abbr_details, abbr_tags, abbr_fourth, name_max),
+ (true, true, true, None)
+ );
+ }
+
+ #[test]
+ fn test_8_5_tier_4_truncate_session_names() {
+ // full_total = 20 + 30 + 11 + 15 + 4 = 80
+ // after abbr fourth: 20 + 10 + 3 + 5 + 4 = 42
+ // max_cols = 30: doesn't fit at tier 3 (42 > 30)
+ // available_for_name = 30 - (10 + 3 + 5 + 4) = 8
+ let (abbr_details, abbr_tags, abbr_fourth, name_max) =
+ compute_reduction_tier(20, 30, 11, 15, 10, 5, 30);
+ assert_eq!((abbr_details, abbr_tags, abbr_fourth), (true, true, true));
+ assert_eq!(name_max, Some(8)); // 30 - (10 + 3 + 5 + 4) = 8
+ }
+}
diff --git a/default-plugins/session-manager/src/ui/mod.rs b/default-plugins/session-manager/src/ui/mod.rs
index b86db946..9503b957 100644
--- a/default-plugins/session-manager/src/ui/mod.rs
+++ b/default-plugins/session-manager/src/ui/mod.rs
@@ -1,5 +1,6 @@
pub mod components;
pub mod welcome_screen;
+use std::time::Duration;
use zellij_tile::prelude::*;
use crate::session_list::{SelectedIndex, SessionList};
@@ -227,6 +228,7 @@ pub struct SessionUiInfo {
pub tabs: Vec<TabUiInfo>,
pub connected_users: usize,
pub is_current_session: bool,
+ pub creation_time: Duration,
}
impl SessionUiInfo {
@@ -240,6 +242,7 @@ impl SessionUiInfo {
.collect(),
connected_users: session_info.connected_clients,
is_current_session: session_info.is_current_session,
+ creation_time: session_info.creation_time,
}
}
pub fn line_count(&self, selected_index: &SelectedIndex) -> usize {
@@ -293,7 +296,7 @@ impl TabUiInfo {
.map(|p| {
p.iter()
.filter_map(|pane_info| {
- if pane_info.is_selectable {
+ if pane_info.is_selectable && !pane_info.is_suppressed {
Some(PaneUiInfo {
name: pane_info.title.clone(),
exit_code: pane_info.exit_status.clone(),