diff --git a/src/cortex-tui-components/src/dropdown.rs b/src/cortex-tui-components/src/dropdown.rs index fbae13f..3a2560d 100644 --- a/src/cortex-tui-components/src/dropdown.rs +++ b/src/cortex-tui-components/src/dropdown.rs @@ -99,7 +99,7 @@ impl DropdownState { /// Select the next item. pub fn select_next(&mut self) { - if self.items.is_empty() { + if self.items.is_empty() || self.max_visible == 0 { return; } self.selected = (self.selected + 1) % self.items.len(); @@ -108,7 +108,7 @@ impl DropdownState { /// Select the previous item. pub fn select_prev(&mut self) { - if self.items.is_empty() { + if self.items.is_empty() || self.max_visible == 0 { return; } self.selected = if self.selected == 0 { diff --git a/src/cortex-tui-components/src/scroll.rs b/src/cortex-tui-components/src/scroll.rs index b80145e..38497b8 100644 --- a/src/cortex-tui-components/src/scroll.rs +++ b/src/cortex-tui-components/src/scroll.rs @@ -119,12 +119,16 @@ impl ScrollState { /// /// Adjusts offset if necessary to make the item visible. pub fn ensure_visible(&mut self, index: usize) { + // Guard against zero visible items to prevent underflow + if self.visible == 0 { + return; + } if index < self.offset { // Item is above visible area - scroll up self.offset = index; } else if index >= self.offset + self.visible { // Item is below visible area - scroll down - self.offset = index.saturating_sub(self.visible - 1); + self.offset = index.saturating_sub(self.visible.saturating_sub(1)); } self.clamp_offset(); } diff --git a/src/cortex-tui-components/src/selection_list.rs b/src/cortex-tui-components/src/selection_list.rs index f25ab1b..bd08658 100644 --- a/src/cortex-tui-components/src/selection_list.rs +++ b/src/cortex-tui-components/src/selection_list.rs @@ -572,7 +572,7 @@ impl SelectionList { && let Some(reason) = &item.disabled_reason { let reason_str = format!(" {}", reason); - let reason_x = x + width - reason_str.len() as u16 - 1; + let reason_x = x.saturating_add(width.saturating_sub(reason_str.len() as u16 + 1)); if reason_x > col + 2 { buf.set_string( reason_x, @@ -651,7 +651,11 @@ impl SelectionList { buf.set_string(x + 2, area.y, &display_text, text_style); - let cursor_x = x + 2 + self.search_query.len() as u16; + // Use character count for cursor position, and account for truncation + let query_char_count = self.search_query.chars().count(); + let display_char_count = display_text.chars().count(); + let cursor_offset = display_char_count.min(query_char_count) as u16; + let cursor_x = x + 2 + cursor_offset; if cursor_x < area.right().saturating_sub(1) { buf[(cursor_x, area.y)].set_bg(self.colors.accent); buf[(cursor_x, area.y)].set_fg(self.colors.void); diff --git a/src/cortex-tui/src/cards/commands.rs b/src/cortex-tui/src/cards/commands.rs index b777c5a..25226b4 100644 --- a/src/cortex-tui/src/cards/commands.rs +++ b/src/cortex-tui/src/cards/commands.rs @@ -225,8 +225,9 @@ impl CardView for CommandsCard { fn desired_height(&self, max_height: u16, _width: u16) -> u16 { // Base height for list items + search bar + some padding - let command_count = self.commands.len() as u16; - let content_height = command_count + 2; // +2 for search bar and padding + // Use saturating conversion to prevent overflow when count > u16::MAX + let command_count = u16::try_from(self.commands.len()).unwrap_or(u16::MAX); + let content_height = command_count.saturating_add(2); // +2 for search bar and padding // Clamp between min 5 and max 14, respecting max_height content_height.clamp(5, 14).min(max_height) diff --git a/src/cortex-tui/src/cards/models.rs b/src/cortex-tui/src/cards/models.rs index a5d0e48..a7abf75 100644 --- a/src/cortex-tui/src/cards/models.rs +++ b/src/cortex-tui/src/cards/models.rs @@ -147,8 +147,9 @@ impl CardView for ModelsCard { fn desired_height(&self, max_height: u16, _width: u16) -> u16 { // Base height for list items + search bar + some padding - let model_count = self.models.len() as u16; - let content_height = model_count + 2; // +2 for search bar and padding + // Use saturating conversion to prevent overflow when count > u16::MAX + let model_count = u16::try_from(self.models.len()).unwrap_or(u16::MAX); + let content_height = model_count.saturating_add(2); // +2 for search bar and padding // Clamp between min 5 and max 12, respecting max_height content_height.clamp(5, 12).min(max_height) diff --git a/src/cortex-tui/src/cards/sessions.rs b/src/cortex-tui/src/cards/sessions.rs index 76c67a0..b856f91 100644 --- a/src/cortex-tui/src/cards/sessions.rs +++ b/src/cortex-tui/src/cards/sessions.rs @@ -207,7 +207,9 @@ impl CardView for SessionsCard { fn desired_height(&self, max_height: u16, _width: u16) -> u16 { // Base height: sessions + header + search bar + padding - let content_height = self.sessions.len() as u16 + 3; + // Use saturating conversion to prevent overflow when count > u16::MAX + let session_count = u16::try_from(self.sessions.len()).unwrap_or(u16::MAX); + let content_height = session_count.saturating_add(3); let min_height = 5; let max_desired = 15; content_height diff --git a/src/cortex-tui/src/interactive/renderer.rs b/src/cortex-tui/src/interactive/renderer.rs index d10598a..763989a 100644 --- a/src/cortex-tui/src/interactive/renderer.rs +++ b/src/cortex-tui/src/interactive/renderer.rs @@ -109,7 +109,13 @@ impl<'a> InteractiveWidget<'a> { let hints_height = 1; let border_height = 2; - (items_count as u16) + header_height + search_height + hints_height + border_height + // Use saturating conversion to prevent overflow when items_count exceeds u16::MAX + let items_height = u16::try_from(items_count).unwrap_or(u16::MAX); + items_height + .saturating_add(header_height) + .saturating_add(search_height) + .saturating_add(hints_height) + .saturating_add(border_height) } } diff --git a/src/cortex-tui/src/widgets/autocomplete.rs b/src/cortex-tui/src/widgets/autocomplete.rs index 77ecce3..3eefe69 100644 --- a/src/cortex-tui/src/widgets/autocomplete.rs +++ b/src/cortex-tui/src/widgets/autocomplete.rs @@ -77,10 +77,11 @@ impl<'a> AutocompletePopup<'a> { let item_count = self.state.visible_items().len() as u16; let height = item_count * ITEM_HEIGHT + 2; // +2 for borders - // Calculate width based on content + // Calculate width based on visible/filtered items only (not all items) + // This prevents the popup from being too wide when the filtered list is smaller let content_width = self .state - .items + .visible_items() .iter() .map(|item| { let icon_width = if item.icon != '\0' { 2 } else { 0 }; @@ -204,11 +205,19 @@ impl Widget for AutocompletePopup<'_> { let (width, height) = self.calculate_dimensions(); - // Position the popup above the input area - // We assume `area` is positioned where the popup should appear + // Position the popup above the input area if there's room, otherwise below + // This prevents the popup from going off-screen at the top + let y = if area.y >= height { + // Enough room above - position popup above the input + area.y.saturating_sub(height) + } else { + // Not enough room above - position popup below the input + area.bottom() + }; + let popup_area = Rect { x: area.x, - y: area.y.saturating_sub(height), + y, width: width.min(area.width), height, }; diff --git a/src/cortex-tui/src/widgets/help_browser/render.rs b/src/cortex-tui/src/widgets/help_browser/render.rs index ae0d035..4f11d99 100644 --- a/src/cortex-tui/src/widgets/help_browser/render.rs +++ b/src/cortex-tui/src/widgets/help_browser/render.rs @@ -152,7 +152,9 @@ impl<'a> HelpBrowser<'a> { /// Renders the content pane. fn render_content(&self, area: Rect, buf: &mut Buffer) { - let section = self.state.current_section(); + let Some(section) = self.state.current_section() else { + return; + }; let mut y = area.y; let scroll = self.state.content_scroll; let mut line_idx = 0; diff --git a/src/cortex-tui/src/widgets/help_browser/state.rs b/src/cortex-tui/src/widgets/help_browser/state.rs index ebfe52c..da95936 100644 --- a/src/cortex-tui/src/widgets/help_browser/state.rs +++ b/src/cortex-tui/src/widgets/help_browser/state.rs @@ -148,8 +148,13 @@ impl HelpBrowserState { } /// Returns the currently selected section. - pub fn current_section(&self) -> &HelpSection { - &self.sections[self.selected_section] + /// + /// Returns `None` if the sections vector is empty. + pub fn current_section(&self) -> Option<&HelpSection> { + if self.sections.is_empty() { + return None; + } + self.sections.get(self.selected_section) } /// Handles character input for search. diff --git a/src/cortex-tui/src/widgets/help_browser/tests.rs b/src/cortex-tui/src/widgets/help_browser/tests.rs index ed8a583..8772517 100644 --- a/src/cortex-tui/src/widgets/help_browser/tests.rs +++ b/src/cortex-tui/src/widgets/help_browser/tests.rs @@ -43,7 +43,10 @@ mod tests { #[test] fn test_help_browser_state_with_topic() { let state = HelpBrowserState::new().with_topic(Some("keyboard")); - assert_eq!(state.current_section().id, "keyboard"); + assert_eq!( + state.current_section().expect("should have section").id, + "keyboard" + ); } #[test] @@ -220,10 +223,16 @@ mod tests { #[test] fn test_current_section() { let mut state = HelpBrowserState::new(); - assert_eq!(state.current_section().id, "getting-started"); + assert_eq!( + state.current_section().expect("should have section").id, + "getting-started" + ); state.select_next(); - assert_eq!(state.current_section().id, "keyboard"); + assert_eq!( + state.current_section().expect("should have section").id, + "keyboard" + ); } #[test] @@ -232,4 +241,11 @@ mod tests { assert!(!state.sections.is_empty()); assert_eq!(state.selected_section, 0); } + + #[test] + fn test_current_section_empty_sections() { + let mut state = HelpBrowserState::new(); + state.sections.clear(); + assert!(state.current_section().is_none()); + } } diff --git a/src/cortex-tui/src/widgets/mention_popup.rs b/src/cortex-tui/src/widgets/mention_popup.rs index 7c68640..65fb12c 100644 --- a/src/cortex-tui/src/widgets/mention_popup.rs +++ b/src/cortex-tui/src/widgets/mention_popup.rs @@ -85,12 +85,12 @@ impl<'a> MentionPopup<'a> { let item_count = self.state.visible_results().len() as u16; let height = (item_count + 2).min(MAX_HEIGHT + 2); // +2 for borders - // Calculate width based on content + // Calculate width based on content (use chars().count() for Unicode support) let content_width = self .state .results() .iter() - .map(|p| p.to_string_lossy().len()) + .map(|p| p.to_string_lossy().chars().count()) .max() .unwrap_or(20) as u16; @@ -195,8 +195,11 @@ impl Widget for MentionPopup<'_> { let (width, height) = self.calculate_dimensions(area); - // Position the popup - let popup_area = if self.above { + // Position the popup - check if it fits above, otherwise render below + let fits_above = area.y >= height; + let render_above = self.above && fits_above; + + let popup_area = if render_above { Rect::new(area.x, area.y.saturating_sub(height), width, height) } else { Rect::new( diff --git a/src/cortex-tui/src/widgets/scrollable_dropdown.rs b/src/cortex-tui/src/widgets/scrollable_dropdown.rs index e4c07fd..7f7f49b 100644 --- a/src/cortex-tui/src/widgets/scrollable_dropdown.rs +++ b/src/cortex-tui/src/widgets/scrollable_dropdown.rs @@ -254,9 +254,12 @@ impl<'a> ScrollableDropdown<'a> { /// Returns visible items slice. fn visible_items(&self) -> &[DropdownItem] { - let start = self.scroll_offset; + if self.max_visible == 0 || self.items.is_empty() { + return &[]; + } + let start = self.scroll_offset.min(self.items.len()); let end = (start + self.max_visible).min(self.items.len()); - &self.items[start..end] + self.items.get(start..end).unwrap_or(&[]) } /// Renders a single item. @@ -459,7 +462,7 @@ pub fn calculate_scroll_offset( max_visible: usize, total_items: usize, ) -> usize { - if total_items <= max_visible { + if max_visible == 0 || total_items <= max_visible { return 0; } @@ -468,7 +471,7 @@ pub fn calculate_scroll_offset( selected } else if selected >= current_offset + max_visible { // Selected item is below visible area - scroll down - selected.saturating_sub(max_visible - 1) + selected.saturating_sub(max_visible.saturating_sub(1)) } else { // Selected item is visible - no change needed current_offset @@ -482,7 +485,7 @@ pub fn select_prev( max_visible: usize, total_items: usize, ) -> (usize, usize) { - if total_items == 0 { + if total_items == 0 || max_visible == 0 { return (0, 0); } @@ -511,7 +514,7 @@ pub fn select_next( max_visible: usize, total_items: usize, ) -> (usize, usize) { - if total_items == 0 { + if total_items == 0 || max_visible == 0 { return (0, 0); } @@ -521,8 +524,8 @@ pub fn select_next( // Wrapped to start 0 } else if new_selected >= scroll_offset + max_visible { - // Need to scroll down - new_selected.saturating_sub(max_visible - 1) + // Need to scroll down - use saturating_sub to prevent underflow + new_selected.saturating_sub(max_visible.saturating_sub(1)) } else { scroll_offset };