//! Interactive modal for selectively importing Claude settings. //! //! Shown when the user runs `/import-claude` (in-session) or presses `i` on //! the welcome screen with new Claude settings detected. Users review each //! discovered item, toggle which to import, and confirm. Only checked items //! are written to `.kigi/config.toml`. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use kigi_shell::claude_import::{ImportPlan, ImportableItem, PathKind, find_project_root}; use kigi_workspace::permission::types::RuleAction; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use std::path::PathBuf; use crate::theme::Theme; use crate::views::modal_window::{ FoldInfo, ModalSizing, ModalWindowConfig, ModalWindowOutcome, ModalWindowState, Shortcut, handle_modal_key, handle_modal_mouse, render_modal_window, }; /// Sentinel ID for non-clickable hint shortcuts. Not routable because /// only `clickable: true` shortcuts generate hit-test areas. const SHORTCUT_ID_HINT: usize = usize::MAX; /// Shortcut IDs for the import-claude modal footer. const SHORTCUT_ID_SELECT_ALL: usize = 0; const SHORTCUT_ID_SELECT_NONE: usize = 1; const SHORTCUT_ID_CONFIRM: usize = 2; const SHORTCUT_ID_CANCEL: usize = 3; /// State for the import-claude modal. pub struct ImportClaudeModalState { /// The full plan as scanned from `.claude/` sources. pub plan: ImportPlan, /// Working directory used to resolve project paths. pub cwd: PathBuf, /// Per-item selection (parallel to flattened item list). pub selected: Vec, /// Currently focused row in the flattened list (0-indexed). pub focus: usize, /// Top of visible scroll window. pub scroll_offset: usize, /// Screen rect where rows are rendered -- populated by the renderer so /// `handle_mouse` can map click coordinates back to row indices. `None` /// until the first draw. pub content_area: Option, /// Shared modal window chrome state (close button, shortcuts, hover). pub window: ModalWindowState, /// Whether the mouse is hovering over the fold indicator on the /// focused header row. pub fold_indicator_hovered: bool, /// Section keys currently collapsed. Uses stable string keys (not row /// indices) so they survive row-list rebuilds. pub collapsed: std::collections::HashSet, } /// Outcome of an input event for the caller to act on. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ImportClaudeModalOutcome { /// User pressed Esc — cancel without importing. Cancelled, /// User pressed Enter — import the items whose `selected` is true. Confirmed, /// State changed; caller should re-render. Changed, /// Key not consumed. Unchanged, } /// One row in the flattened display list. Used for navigation, rendering, /// and translating the focused index back to a (scope, item-index) tuple. #[derive(Debug, Clone)] enum Row { /// Top-level scope header (Global / Project). Selectable: toggling toggles /// every item in the scope. ScopeHeader { label: String, flat_indices: Vec, section_key: String, }, /// Type subgroup header (Permissions / Env vars / MCP servers / Hooks / /// Paths). Selectable: toggling toggles every item in the subgroup. TypeHeader { kind: ItemKind, flat_indices: Vec, section_key: String, }, /// Item row, with index into the source vec. Item { scope: Scope, item_index: usize, flat_index: usize, // index into `selected` }, /// Blank spacer. Blank, } /// Group items by type for nicer display + bulk-toggle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ItemKind { Permission, EnvVar, McpServer, Hook, PathEntry, } impl ItemKind { fn of(item: &ImportableItem) -> Self { match item { ImportableItem::Permission(_) => Self::Permission, ImportableItem::EnvVar { .. } => Self::EnvVar, ImportableItem::McpServer { .. } => Self::McpServer, ImportableItem::Hook { .. } => Self::Hook, ImportableItem::PathEntry { .. } => Self::PathEntry, } } fn label(&self) -> &'static str { match self { Self::Permission => "Permissions", Self::EnvVar => "Env vars", Self::McpServer => "MCP servers", Self::Hook => "Hooks", Self::PathEntry => "Paths", } } /// Stable sort order for grouping within a scope. fn order(&self) -> u8 { match self { Self::Permission => 0, Self::EnvVar => 1, Self::McpServer => 2, Self::Hook => 3, Self::PathEntry => 4, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Scope { Global, Project, } impl ImportClaudeModalState { /// Build a new modal with all items selected by default. pub fn new(plan: ImportPlan, cwd: PathBuf) -> Self { let total = plan.global_items.len() + plan.project_items.len(); let selected = vec![true; total]; // Focus the first selectable row (skip headers if any). let no_collapsed = std::collections::HashSet::new(); let rows = build_rows(&plan, &cwd, &no_collapsed); let focus = rows .iter() .position(|r| matches!(r, Row::Item { .. })) .unwrap_or(0); Self { plan, cwd, selected, focus, scroll_offset: 0, content_area: None, window: ModalWindowState::new(), fold_indicator_hovered: false, collapsed: std::collections::HashSet::new(), } } /// Number of items currently selected for import. pub fn selected_count(&self) -> usize { self.selected.iter().filter(|&&b| b).count() } /// Total number of items in the plan. pub fn total_count(&self) -> usize { self.selected.len() } /// Build a filtered ImportPlan containing only the user-selected items. pub fn filtered_plan(&self) -> ImportPlan { let mut plan = ImportPlan::default(); let global_count = self.plan.global_items.len(); for (i, item) in self.plan.global_items.iter().enumerate() { if self.selected.get(i).copied().unwrap_or(false) { plan.global_items.push(item.clone()); } } for (i, item) in self.plan.project_items.iter().enumerate() { let flat_idx = global_count + i; if self.selected.get(flat_idx).copied().unwrap_or(false) { plan.project_items.push(item.clone()); } } plan } /// Handle a mouse event. Recognizes: /// - **Left click on an item row**: focus + toggle that item. /// - **Left click on a section/type header**: focus + tri-state toggle /// (selects all if any unselected, deselects all if everything selected). /// - **Scroll wheel**: scroll up/down through the content. /// - Clicks outside the content area are ignored. pub fn handle_mouse( &mut self, kind: crossterm::event::MouseEventKind, column: u16, row: u16, ) -> ImportClaudeModalOutcome { use crossterm::event::{MouseButton, MouseEventKind}; // Let the shared modal window chrome handle close button, shortcuts, // and click-outside-to-close first. let chrome_outcome = handle_modal_mouse(&mut self.window, kind, column, row); match chrome_outcome { ModalWindowOutcome::CloseRequested => { return ImportClaudeModalOutcome::Cancelled; } ModalWindowOutcome::ShortcutActivated(id) => { return self.dispatch_shortcut_by_id(id); } ModalWindowOutcome::Handled => { return ImportClaudeModalOutcome::Changed; } _ => { // Mouse handler never returns fold outcomes; fall // through to content handling. } } let Some(area) = self.content_area else { return ImportClaudeModalOutcome::Unchanged; }; match kind { MouseEventKind::Moved => { // Move focus to the selectable row under the cursor for // hover feedback. Clicks outside the content area, header // gaps, or blank spacers don't move focus -- but we still // need to honour any hover-state transition recorded above. if column < area.x || column >= area.x + area.width || row < area.y || row >= area.y + area.height { return ImportClaudeModalOutcome::Unchanged; } let visible_offset = (row - area.y) as usize; let row_index = self.scroll_offset + visible_offset; let rows = build_rows(&self.plan, &self.cwd, &self.collapsed); let Some(target) = rows.get(row_index) else { return ImportClaudeModalOutcome::Unchanged; }; if matches!(target, Row::Blank) { return ImportClaudeModalOutcome::Unchanged; } // Check if hovering over the fold indicator on a header. let on_fold = match target { Row::ScopeHeader { .. } => column >= area.x && column < area.x + 2, Row::TypeHeader { .. } => column >= area.x + 2 && column < area.x + 4, _ => false, }; let focus_changed = self.focus != row_index; let fold_changed = self.fold_indicator_hovered != on_fold; self.focus = row_index; self.fold_indicator_hovered = on_fold; if focus_changed || fold_changed { ImportClaudeModalOutcome::Changed } else { ImportClaudeModalOutcome::Unchanged } } MouseEventKind::ScrollUp => { if self.scroll_offset > 0 { self.scroll_offset -= 1; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } MouseEventKind::ScrollDown => { let rows = build_rows(&self.plan, &self.cwd, &self.collapsed); let visible = area.height as usize; let max_offset = rows.len().saturating_sub(visible); if self.scroll_offset < max_offset { self.scroll_offset += 1; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } MouseEventKind::Down(MouseButton::Left) => { // Bail if the click is outside the content rect. if column < area.x || column >= area.x + area.width || row < area.y || row >= area.y + area.height { return ImportClaudeModalOutcome::Unchanged; } // Map row coordinate to a row index via the visible window. let visible_offset = (row - area.y) as usize; let row_index = self.scroll_offset + visible_offset; let rows = build_rows(&self.plan, &self.cwd, &self.collapsed); let Some(target) = rows.get(row_index) else { return ImportClaudeModalOutcome::Unchanged; }; self.focus = row_index; // Check if the click landed on the fold indicator (▶/▼). // ScopeHeaders have indent=0, TypeHeaders indent=2. The // indicator is 2 chars wide starting at content_area.x + indent. let fold_clicked = match target { Row::ScopeHeader { section_key, .. } => { let indicator_start = area.x; (column >= indicator_start && column < indicator_start + 2) .then(|| section_key.clone()) } Row::TypeHeader { section_key, .. } => { let indicator_start = area.x + 2; // indent=2 (column >= indicator_start && column < indicator_start + 2) .then(|| section_key.clone()) } _ => None, }; if let Some(key) = fold_clicked { if !self.collapsed.remove(&key) { self.collapsed.insert(key); } let new_rows = build_rows(&self.plan, &self.cwd, &self.collapsed); if self.focus >= new_rows.len() { self.focus = new_rows.len().saturating_sub(1); } return ImportClaudeModalOutcome::Changed; } // Click elsewhere on a header: toggle selection. let group_indices: Option> = match target { Row::ScopeHeader { flat_indices, .. } | Row::TypeHeader { flat_indices, .. } => Some(flat_indices.clone()), _ => None, }; if let Some(indices) = group_indices { let any_unselected = indices .iter() .any(|i| !self.selected.get(*i).copied().unwrap_or(false)); let new_state = any_unselected; for i in indices { if let Some(s) = self.selected.get_mut(i) { *s = new_state; } } return ImportClaudeModalOutcome::Changed; } if let Row::Item { flat_index, .. } = target && let Some(s) = self.selected.get_mut(*flat_index) { *s = !*s; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } _ => ImportClaudeModalOutcome::Unchanged, } } /// Apply the action associated with a clicked footer shortcut (by ID). fn dispatch_shortcut_by_id(&mut self, id: usize) -> ImportClaudeModalOutcome { match id { SHORTCUT_ID_SELECT_ALL => { self.selected.iter_mut().for_each(|s| *s = true); ImportClaudeModalOutcome::Changed } SHORTCUT_ID_SELECT_NONE => { self.selected.iter_mut().for_each(|s| *s = false); ImportClaudeModalOutcome::Changed } SHORTCUT_ID_CONFIRM => ImportClaudeModalOutcome::Confirmed, SHORTCUT_ID_CANCEL => ImportClaudeModalOutcome::Cancelled, _ => ImportClaudeModalOutcome::Unchanged, } } pub fn handle_key(&mut self, key: &KeyEvent) -> ImportClaudeModalOutcome { // Build fold state from the focused row so handle_modal_key can // decide collapse/expand/jump-to-parent generically. let rows = build_rows(&self.plan, &self.cwd, &self.collapsed); let fold_info = rows.get(self.focus).and_then(|row| match row { Row::ScopeHeader { section_key, .. } => Some(FoldInfo { collapsible: true, expanded: !self.collapsed.contains(section_key.as_str()), has_details: false, details_expanded: false, parent_index: None, }), Row::TypeHeader { section_key, .. } => { let parent = rows[..self.focus] .iter() .rposition(|r| matches!(r, Row::ScopeHeader { .. })); Some(FoldInfo { collapsible: true, expanded: !self.collapsed.contains(section_key.as_str()), has_details: false, details_expanded: false, parent_index: parent, }) } Row::Item { .. } => { let parent = rows[..self.focus] .iter() .rposition(|r| matches!(r, Row::ScopeHeader { .. } | Row::TypeHeader { .. })); Some(FoldInfo { collapsible: false, expanded: false, has_details: false, details_expanded: false, parent_index: parent, }) } Row::Blank => None, }); let config = ModalWindowConfig { title: "", tabs: None, shortcuts: &[], sizing: Default::default(), fold_info, }; match handle_modal_key(&mut self.window, key, &config) { ModalWindowOutcome::CloseRequested => return ImportClaudeModalOutcome::Cancelled, ModalWindowOutcome::Handled => return ImportClaudeModalOutcome::Changed, ModalWindowOutcome::CollapseGroup => { if let Some(Row::ScopeHeader { section_key, .. }) | Some(Row::TypeHeader { section_key, .. }) = rows.get(self.focus) { self.collapsed.insert(section_key.clone()); let new_rows = build_rows(&self.plan, &self.cwd, &self.collapsed); if self.focus >= new_rows.len() { self.focus = new_rows.len().saturating_sub(1); } } return ImportClaudeModalOutcome::Changed; } ModalWindowOutcome::ExpandGroup => { if let Some(Row::ScopeHeader { section_key, .. }) | Some(Row::TypeHeader { section_key, .. }) = rows.get(self.focus) { self.collapsed.remove(section_key); } return ImportClaudeModalOutcome::Changed; } ModalWindowOutcome::JumpToParent(idx) => { self.focus = idx; return ImportClaudeModalOutcome::Changed; } _ => {} } if (key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT)) && !crate::input::key::is_altgr(key.modifiers) { return ImportClaudeModalOutcome::Unchanged; } match key.code { KeyCode::Enter => ImportClaudeModalOutcome::Confirmed, KeyCode::Up | KeyCode::Char('k') => { if let Some(prev) = prev_selectable_row(&rows, self.focus) { self.focus = prev; ImportClaudeModalOutcome::Changed } else { ImportClaudeModalOutcome::Unchanged } } KeyCode::Down | KeyCode::Char('j') => { if let Some(next) = next_selectable_row(&rows, self.focus) { self.focus = next; ImportClaudeModalOutcome::Changed } else { ImportClaudeModalOutcome::Unchanged } } KeyCode::Home | KeyCode::Char('g') => { let first = rows.iter().position(is_selectable); if let Some(idx) = first && idx != self.focus { self.focus = idx; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } KeyCode::End | KeyCode::Char('G') => { let last = rows.iter().rposition(is_selectable); if let Some(idx) = last && idx != self.focus { self.focus = idx; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } KeyCode::PageUp => { let step = self .content_area .map(|a| a.height.saturating_sub(1) as usize) .unwrap_or(10) .max(1); let mut target = self.focus; for _ in 0..step { if let Some(prev) = prev_selectable_row(&rows, target) { target = prev; } else { break; } } if target != self.focus { self.focus = target; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } KeyCode::PageDown => { let step = self .content_area .map(|a| a.height.saturating_sub(1) as usize) .unwrap_or(10) .max(1); let mut target = self.focus; for _ in 0..step { if let Some(next) = next_selectable_row(&rows, target) { target = next; } else { break; } } if target != self.focus { self.focus = target; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } KeyCode::Char(' ') => { let row = rows.get(self.focus); let group_indices: Option> = match row { Some(Row::ScopeHeader { flat_indices, .. }) | Some(Row::TypeHeader { flat_indices, .. }) => Some(flat_indices.clone()), _ => None, }; if let Some(indices) = group_indices { // Tri-state toggle for groups: if any item is unselected, select all; // otherwise (all selected) deselect all. let any_unselected = indices .iter() .any(|i| !self.selected.get(*i).copied().unwrap_or(false)); let new_state = any_unselected; for i in indices { if let Some(s) = self.selected.get_mut(i) { *s = new_state; } } return ImportClaudeModalOutcome::Changed; } if let Some(Row::Item { flat_index, .. }) = row && let Some(s) = self.selected.get_mut(*flat_index) { *s = !*s; return ImportClaudeModalOutcome::Changed; } ImportClaudeModalOutcome::Unchanged } KeyCode::Char('a') => { self.selected.iter_mut().for_each(|s| *s = true); ImportClaudeModalOutcome::Changed } KeyCode::Char('n') => { self.selected.iter_mut().for_each(|s| *s = false); ImportClaudeModalOutcome::Changed } _ => ImportClaudeModalOutcome::Unchanged, } } } /// Render the modal centered in `area` using the shared `ModalWindow` /// chrome. Content (the checkbox tree) is drawn into the content area /// returned by [`render_modal_window`]. pub fn render_import_claude_modal( buf: &mut Buffer, area: Rect, state: &mut ImportClaudeModalState, theme: &Theme, compact: bool, ) { let confirm_label = format!("Enter import {}", state.selected_count()); let shortcuts = [ Shortcut { label: "\u{2191}\u{2193} navigate", clickable: false, id: SHORTCUT_ID_HINT, }, Shortcut { label: "space toggle", clickable: false, id: SHORTCUT_ID_HINT, }, Shortcut { label: "\u{2190}\u{2192} fold", clickable: false, id: SHORTCUT_ID_HINT, }, Shortcut { label: "a all", clickable: true, id: SHORTCUT_ID_SELECT_ALL, }, Shortcut { label: "n none", clickable: true, id: SHORTCUT_ID_SELECT_NONE, }, Shortcut { label: &confirm_label, clickable: true, id: SHORTCUT_ID_CONFIRM, }, Shortcut { label: "Esc cancel", clickable: true, id: SHORTCUT_ID_CANCEL, }, ]; let config = ModalWindowConfig { title: "Import Claude settings", tabs: None, shortcuts: &shortcuts, sizing: ModalSizing::default().with_compact(compact), fold_info: None, }; let Some(areas) = render_modal_window(buf, area, &mut state.window, &config, theme) else { return; }; let content_area = areas.content; // Stash the rendered content rect so the mouse handler can map clicks // back to row indices without re-deriving the layout. state.content_area = Some(content_area); // Build rows + ensure focus is in viewport. let rows = build_rows(&state.plan, &state.cwd, &state.collapsed); if state.focus >= rows.len() { state.focus = rows.len().saturating_sub(1); } let visible = content_area.height as usize; if state.focus < state.scroll_offset { state.scroll_offset = state.focus; } else if state.focus >= state.scroll_offset + visible { state.scroll_offset = state.focus + 1 - visible; } // Render visible rows one at a time so each row can pre-fill its full // width with `bg_highlight` when focused. A single Paragraph render // would only colour the text spans; the welcome menu uses the same // per-row fill pattern and we mirror it here for visual consistency. for (row_idx, (i, row)) in rows .iter() .enumerate() .skip(state.scroll_offset) .take(visible) .enumerate() { let y = content_area.y + row_idx as u16; let is_focused = i == state.focus; let is_blank = matches!(row, Row::Blank); if is_focused && !is_blank { let hover_bg = Style::default().bg(theme.bg_highlight); for x in content_area.x..content_area.x + content_area.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(hover_bg); } } } let line = render_row_dispatch( row, is_focused, &state.plan, &state.selected, theme, &state.collapsed, is_focused && state.fold_indicator_hovered, ); let row_rect = Rect { x: content_area.x, y, width: content_area.width, height: 1, }; Paragraph::new(line).render(row_rect, buf); } } /// Build the flat row list for navigation and rendering. fn build_rows( plan: &ImportPlan, cwd: &std::path::Path, collapsed: &std::collections::HashSet, ) -> Vec { let mut rows = Vec::new(); let mut flat_index = 0; if !plan.global_items.is_empty() { let scope_start = flat_index; let scope_key = format!("scope:{:?}", Scope::Global); let label = "Global ~/.kigi/config.toml".to_string(); // Placeholder header; flat_indices filled after children are pushed. let scope_header_pos = rows.len(); rows.push(Row::ScopeHeader { label, flat_indices: Vec::new(), section_key: scope_key.clone(), }); let scope_collapsed = collapsed.contains(&scope_key); push_grouped_items( &mut rows, &mut flat_index, Scope::Global, &plan.global_items, collapsed, scope_collapsed, ); // Backfill scope flat_indices. if let Row::ScopeHeader { flat_indices, .. } = &mut rows[scope_header_pos] { *flat_indices = (scope_start..flat_index).collect(); } rows.push(Row::Blank); } if !plan.project_items.is_empty() { let scope_start = flat_index; let scope_key = format!("scope:{:?}", Scope::Project); let project_config = find_project_root(cwd).join(".kigi").join("config.toml"); let label = format!("Project {}", project_config.display()); let scope_header_pos = rows.len(); rows.push(Row::ScopeHeader { label, flat_indices: Vec::new(), section_key: scope_key.clone(), }); let scope_collapsed = collapsed.contains(&scope_key); push_grouped_items( &mut rows, &mut flat_index, Scope::Project, &plan.project_items, collapsed, scope_collapsed, ); if let Row::ScopeHeader { flat_indices, .. } = &mut rows[scope_header_pos] { *flat_indices = (scope_start..flat_index).collect(); } } rows } /// Push a TypeHeader followed by Item rows for each ItemKind present in `items`. /// Items keep their original order within each subgroup. The header tracks the /// flat_indices of items in its subgroup for bulk-toggle. fn push_grouped_items( rows: &mut Vec, flat_index: &mut usize, scope: Scope, items: &[ImportableItem], collapsed: &std::collections::HashSet, scope_collapsed: bool, ) { // The `selected` Vec is indexed by ORIGINAL item position (so // `filtered_plan` can iterate items in their source-vec order and check // `selected[scope_offset + i]`). When we sort items into display groups // here we must therefore use `scope_offset + item_idx` as the flat_index, // not a monotonic counter — otherwise toggling a displayed row would mark // the wrong slot and unrelated items would import (or fail to import). let scope_offset = *flat_index; let mut indexed: Vec<(usize, ItemKind)> = items .iter() .enumerate() .map(|(i, it)| (i, ItemKind::of(it))) .collect(); indexed.sort_by_key(|(_, k)| k.order()); let mut current_kind: Option = None; let mut header_pos: Option = None; let mut group_indices: Vec = Vec::new(); for (item_idx, kind) in indexed { if Some(kind) != current_kind { // Close out previous group's flat_indices. if let Some(pos) = header_pos && let Row::TypeHeader { flat_indices, .. } = &mut rows[pos] { *flat_indices = std::mem::take(&mut group_indices); } // Open new group. let type_key = format!("type:{scope:?}:{kind:?}"); if !scope_collapsed { header_pos = Some(rows.len()); rows.push(Row::TypeHeader { kind, flat_indices: Vec::new(), section_key: type_key.clone(), }); } else { header_pos = None; } current_kind = Some(kind); } let item_flat = scope_offset + item_idx; // Skip item rows when the scope is collapsed, or when the // type-group is collapsed. let type_key = format!("type:{:?}:{:?}", scope, current_kind.unwrap_or(kind)); let type_collapsed = collapsed.contains(&type_key); if !scope_collapsed && !type_collapsed { rows.push(Row::Item { scope, item_index: item_idx, flat_index: item_flat, }); } group_indices.push(item_flat); } // Close out final group. if let Some(pos) = header_pos && let Row::TypeHeader { flat_indices, .. } = &mut rows[pos] { *flat_indices = group_indices; } *flat_index = scope_offset + items.len(); } /// Dispatch a row to the appropriate renderer. fn render_row_dispatch<'a>( row: &Row, focused: bool, plan: &'a ImportPlan, selected: &[bool], theme: &Theme, collapsed_set: &std::collections::HashSet, fold_hovered: bool, ) -> Line<'a> { match row { Row::ScopeHeader { label, flat_indices, section_key, } => render_header_line( label, flat_indices, selected, focused, theme, /* indent: */ 0, true, collapsed_set.contains(section_key), focused && fold_hovered, ), Row::TypeHeader { kind, flat_indices, section_key, } => { let label = format!("{} ({})", kind.label(), flat_indices.len()); render_header_line( &label, flat_indices, selected, focused, theme, 2, false, collapsed_set.contains(section_key), focused && fold_hovered, ) } Row::Blank => Line::from(""), Row::Item { scope, item_index, flat_index, } => { let item = match scope { Scope::Global => &plan.global_items[*item_index], Scope::Project => &plan.project_items[*item_index], }; let is_selected = selected.get(*flat_index).copied().unwrap_or(false); render_item_line(item, is_selected, focused, theme) } } } /// Render a group header (scope or type) with tri-state checkbox. /// /// `bold_label` makes the label BOLD (for top-level scope headers); type /// subgroup headers use a lighter style to give visual hierarchy. #[allow(clippy::too_many_arguments)] fn render_header_line<'a>( label: &str, flat_indices: &[usize], selected: &[bool], focused: bool, theme: &Theme, indent: usize, bold_label: bool, collapsed: bool, fold_hovered: bool, ) -> Line<'a> { let checked = flat_indices .iter() .filter(|i| selected.get(**i).copied().unwrap_or(false)) .count(); let total = flat_indices.len(); // Brackets stay gray; only the inner mark is colored. This keeps the // visual weight of an unchecked group identical to a checked one and // lets the colored mark act as the actual signal. let (mark, mark_style) = if checked == 0 { (" ", Style::default().fg(theme.gray_dim)) } else if checked == total { ( crate::glyphs::check_mark(), Style::default().fg(theme.accent_success), ) } else { ("~", Style::default().fg(theme.accent_running)) }; let bracket_style = with_bg(Style::default().fg(theme.gray_dim), focused, theme); let mark_style = with_bg(mark_style, focused, theme); let label_base = if bold_label { Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text_secondary) }; let label_style = with_bg(label_base, focused, theme); let pad = " ".repeat(indent); let fold_bg = if focused { Some(theme.bg_highlight) } else { None }; let fold_span = crate::views::modal_window::fold_indicator_span(collapsed, fold_hovered, fold_bg, theme); Line::from(vec![ Span::raw(pad), fold_span, Span::styled("[", bracket_style), Span::styled(mark, mark_style), Span::styled("]", bracket_style), Span::raw(" "), Span::styled(label.to_string(), label_style), ]) } /// Render a single item row with checkbox + label + focus highlight. fn render_item_line<'a>( item: &'a ImportableItem, selected: bool, focused: bool, theme: &Theme, ) -> Line<'a> { // Brackets stay gray; the mark itself is the only colored cell. let (mark, mark_base) = if selected { ( crate::glyphs::check_mark(), Style::default().fg(theme.accent_success), ) } else { (" ", Style::default().fg(theme.gray_dim)) }; let bracket_style = with_bg(Style::default().fg(theme.gray_dim), focused, theme); let mark_style = with_bg(mark_base, focused, theme); let label = format_item_label(item); let label_style = with_bg(Style::default().fg(theme.text_primary), focused, theme); // Items live under TypeHeaders (indent 2) under ScopeHeaders (indent 0). // Indent items at 4 spaces total so they visually nest below their group. Line::from(vec![ Span::raw(" "), Span::styled("[", bracket_style), Span::styled(mark, mark_style), Span::styled("]", bracket_style), Span::raw(" "), Span::styled(label, label_style), ]) } /// Conditionally apply the row-hover background to a span style. /// /// When a row is focused the renderer pre-fills the row's cells with /// `bg_highlight`. We also set bg explicitly on each span so the highlight /// survives even if a span resets its background, and so that hovering /// reads as a continuous bar across the full row width. fn with_bg(style: Style, focused: bool, theme: &Theme) -> Style { if focused { style.bg(theme.bg_highlight) } else { style } } fn format_item_label(item: &ImportableItem) -> String { match item { ImportableItem::Permission(rule) => { let action = match rule.action { RuleAction::Allow => "allow", RuleAction::Deny => "deny", RuleAction::Ask => "ask", }; let pattern = rule.pattern.as_deref().unwrap_or("*"); let tool = format!("{:?}", rule.tool); format!("{action:5} {tool}({pattern})") } ImportableItem::EnvVar { key, value } => { format!("{key} = {value:?}") } ImportableItem::McpServer { name, .. } => name.to_string(), ImportableItem::Hook { event, matcher, command, timeout, } => { let m = matcher.as_deref().unwrap_or("*"); let t = timeout .map(|t| format!(" timeout={}s", t)) .unwrap_or_default(); format!("{event} matcher={m} → {command}{t}") } ImportableItem::PathEntry { kind, path } => { let kind_str = match kind { PathKind::Skill => "skill dir", PathKind::Rule => "rule dir", }; format!("{kind_str}: {path}") } } } fn next_selectable_row(rows: &[Row], from: usize) -> Option { rows.iter() .enumerate() .skip(from + 1) .find(|(_, r)| is_selectable(r)) .map(|(i, _)| i) } fn prev_selectable_row(rows: &[Row], from: usize) -> Option { rows.iter() .enumerate() .take(from) .rev() .find(|(_, r)| is_selectable(r)) .map(|(i, _)| i) } /// Selectable = focusable for navigation. Includes headers (which can be /// bulk-toggled) and items. Excludes Blank spacers. fn is_selectable(row: &Row) -> bool { matches!( row, Row::ScopeHeader { .. } | Row::TypeHeader { .. } | Row::Item { .. } ) } #[cfg(test)] mod tests { use super::*; use kigi_shell::claude_import::PathKind; use kigi_workspace::permission::types::{PatternMode, PermissionRule, ToolFilter}; fn sample_plan() -> ImportPlan { ImportPlan { global_items: vec![ ImportableItem::Permission(PermissionRule { action: RuleAction::Allow, tool: ToolFilter::Bash, pattern: Some("cargo test *".into()), pattern_mode: PatternMode::Glob, }), ImportableItem::EnvVar { key: "RUST_LOG".into(), value: "debug".into(), }, ], project_items: vec![ImportableItem::PathEntry { kind: PathKind::Skill, path: ".claude/skills".into(), }], } } #[test] fn defaults_select_all() { let m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); assert_eq!(m.total_count(), 3); assert_eq!(m.selected_count(), 3); } #[test] fn select_none_then_some() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let n_key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE); assert_eq!(m.handle_key(&n_key), ImportClaudeModalOutcome::Changed); assert_eq!(m.selected_count(), 0); // Move focus to a single Item row (skip past headers). let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); let item_idx = rows .iter() .position(|r| matches!(r, Row::Item { .. })) .expect("sample_plan has items"); m.focus = item_idx; let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE); assert_eq!(m.handle_key(&space), ImportClaudeModalOutcome::Changed); assert_eq!(m.selected_count(), 1); } #[test] fn filtered_plan_drops_unselected() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let n_key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE); m.handle_key(&n_key); // Select only the first global item. m.selected[0] = true; let filtered = m.filtered_plan(); assert_eq!(filtered.global_items.len(), 1); assert_eq!(filtered.project_items.len(), 0); } /// Regression: when items are sorted into display groups (e.g. /// Permissions before MCP servers), de-selecting an MCP server in the /// modal must actually skip THAT MCP server in `filtered_plan` — not /// some other item that happens to share the deselected slot's index. #[test] fn filtered_plan_respects_per_item_selection_after_grouping() { use kigi_shell::claude_import::ImportableItem; use kigi_shell::util::config::{McpServerConfig, McpServerTransportConfig}; use kigi_workspace::permission::types::{PatternMode, PermissionRule, ToolFilter}; // Mix Permissions, MCP servers, and EnvVars in a non-sorted order so // the display order (sorted by ItemKind) differs from the source order. let mcp = |name: &str| ImportableItem::McpServer { name: name.into(), config: Box::new(McpServerConfig { transport: McpServerTransportConfig::Stdio { command: "true".into(), args: vec![], env: None, cwd: None, }, enabled: true, oauth: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, expose_image_base64: None, }), }; let plan = ImportPlan { global_items: vec![ mcp("alpha"), ImportableItem::Permission(PermissionRule { action: RuleAction::Allow, tool: ToolFilter::Bash, pattern: Some("true".into()), pattern_mode: PatternMode::Glob, }), mcp("beta"), ImportableItem::EnvVar { key: "X".into(), value: "y".into(), }, mcp("gamma"), ], project_items: vec![], }; let mut m = ImportClaudeModalState::new(plan, PathBuf::from("/tmp")); // Deselect every MCP server. They are at original indices 0, 2, 4. m.selected[0] = false; m.selected[2] = false; m.selected[4] = false; let filtered = m.filtered_plan(); // Should contain exactly the Permission (idx 1) and EnvVar (idx 3), // and none of alpha/beta/gamma. assert_eq!(filtered.global_items.len(), 2); for item in &filtered.global_items { assert!( !matches!(item, ImportableItem::McpServer { .. }), "de-selected MCP server slipped through: {:?}", item ); } } #[test] fn esc_cancels() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); assert_eq!(m.handle_key(&esc), ImportClaudeModalOutcome::Cancelled); } #[test] fn enter_confirms() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); assert_eq!(m.handle_key(&enter), ImportClaudeModalOutcome::Confirmed); } #[test] fn navigation_lands_on_selectable_rows() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let initial = m.focus; let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); m.handle_key(&down); assert!(m.focus > initial, "focus should advance"); let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); // Headers are now selectable too — just verify it's not a Blank. assert!(matches!( rows.get(m.focus), Some(Row::Item { .. } | Row::ScopeHeader { .. } | Row::TypeHeader { .. }) )); } /// Regression: clicking on an item row with the mouse must toggle that /// item's selection. After the inline-shortcut refactor, the handler /// now consults `state.shortcuts` first; verify a click on a row that /// is NOT a shortcut still falls through to the row-toggle path. #[test] fn mouse_click_on_item_row_toggles() { use crossterm::event::{MouseButton, MouseEventKind}; let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); // Simulate render: stash content_area so handle_mouse can map clicks. m.content_area = Some(ratatui::layout::Rect { x: 10, y: 5, width: 60, height: 20, }); // Find the first item row's display offset within the visible window. let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); let item_row_index = rows .iter() .position(|r| matches!(r, Row::Item { .. })) .expect("sample plan has items"); // With scroll_offset=0 the item appears at content_area.y + item_row_index. let click_y = m.content_area.unwrap().y + item_row_index as u16; let click_x = m.content_area.unwrap().x + 5; // anywhere in the row // Capture initial selection state for that item. let flat_idx = match &rows[item_row_index] { Row::Item { flat_index, .. } => *flat_index, _ => unreachable!(), }; let before = m.selected[flat_idx]; let outcome = m.handle_mouse(MouseEventKind::Down(MouseButton::Left), click_x, click_y); assert_eq!(outcome, ImportClaudeModalOutcome::Changed); assert_eq!( m.selected[flat_idx], !before, "click on item row should toggle its selection" ); } /// Regression: clicking on a scope/type header row should bulk-toggle. #[test] fn mouse_click_on_header_row_bulk_toggles() { use crossterm::event::{MouseButton, MouseEventKind}; let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); m.content_area = Some(ratatui::layout::Rect { x: 10, y: 5, width: 60, height: 20, }); // First row is the Global ScopeHeader (sample_plan has Global items). let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); assert!(matches!(rows.first(), Some(Row::ScopeHeader { .. }))); let click_y = m.content_area.unwrap().y; // top row let click_x = m.content_area.unwrap().x + 5; // All items start selected. assert!(m.selected.iter().all(|&s| s)); m.handle_mouse(MouseEventKind::Down(MouseButton::Left), click_x, click_y); // Global scope items (indices 0, 1) should now be deselected. assert!(!m.selected[0]); assert!(!m.selected[1]); // Project item (index 2) untouched. assert!(m.selected[2]); } #[test] fn space_on_scope_header_toggles_all_in_scope() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); // Focus the first row (should be the Global ScopeHeader). m.focus = 0; let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); assert!(matches!(rows.first(), Some(Row::ScopeHeader { .. }))); // Initially all selected. Space should deselect everything in Global scope. let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE); m.handle_key(&space); // Global has 2 items (indices 0, 1). Project has 1 (index 2). assert!(!m.selected[0]); assert!(!m.selected[1]); assert!(m.selected[2], "project should be unaffected"); // Space again: re-selects all in Global. m.handle_key(&space); assert!(m.selected[0]); assert!(m.selected[1]); } #[test] fn left_on_expanded_scope_header_collapses_it() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); // Focus on the first ScopeHeader (Global). m.focus = 0; let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); let key = match &rows[0] { Row::ScopeHeader { section_key, .. } => section_key.clone(), _ => panic!("first row should be ScopeHeader"), }; assert!(m.collapsed.is_empty(), "nothing collapsed initially"); let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let outcome = m.handle_key(&left); assert_eq!(outcome, ImportClaudeModalOutcome::Changed); assert!( m.collapsed.contains(&key), "section_key should now be in collapsed set" ); } #[test] fn right_on_collapsed_scope_header_expands_it() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); m.focus = 0; let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); let key = match &rows[0] { Row::ScopeHeader { section_key, .. } => section_key.clone(), _ => panic!("first row should be ScopeHeader"), }; // Collapse it first. m.collapsed.insert(key.clone()); assert!(m.collapsed.contains(&key)); let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let outcome = m.handle_key(&right); assert_eq!(outcome, ImportClaudeModalOutcome::Changed); assert!( !m.collapsed.contains(&key), "scope should be expanded again" ); } #[test] fn left_on_item_jumps_to_parent_header() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); // Find the first Item row. let item_idx = rows .iter() .position(|r| matches!(r, Row::Item { .. })) .expect("sample_plan has items"); m.focus = item_idx; assert!(item_idx > 0, "item should not be the first row"); let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let outcome = m.handle_key(&left); assert_eq!(outcome, ImportClaudeModalOutcome::Changed); // Focus should have jumped to the nearest header above the item. assert!(m.focus < item_idx); assert!(matches!( rows[m.focus], Row::ScopeHeader { .. } | Row::TypeHeader { .. } )); } #[test] fn collapse_from_last_header_clamps_focus() { let mut m = ImportClaudeModalState::new(sample_plan(), PathBuf::from("/tmp")); let rows = build_rows(&m.plan, &m.cwd, &m.collapsed); // Find the last TypeHeader -- it's the deepest header near the end. let last_type_idx = rows .iter() .rposition(|r| matches!(r, Row::TypeHeader { .. })) .expect("sample_plan has type headers"); // Verify it has children after it (will shrink on collapse). let rows_after = rows.len() - last_type_idx - 1; assert!(rows_after > 0, "TypeHeader should have items after it"); let row_count_before = rows.len(); m.focus = last_type_idx; let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let outcome = m.handle_key(&left); assert_eq!(outcome, ImportClaudeModalOutcome::Changed); // Row count should have shrunk (children hidden). let new_rows = build_rows(&m.plan, &m.cwd, &m.collapsed); assert!( new_rows.len() < row_count_before, "collapsing should remove child rows" ); // Focus must be valid (within the new row list). The // focus-clamping guard in CollapseGroup ensures this. assert!( m.focus < new_rows.len(), "focus should be within new row bounds" ); // The focused row should still be the TypeHeader (it survives). assert!(matches!(new_rows[m.focus], Row::TypeHeader { .. })); } #[test] fn collapse_hides_children_from_rows() { let plan = sample_plan(); let mut collapsed = std::collections::HashSet::new(); let full_rows = build_rows(&plan, &PathBuf::from("/tmp"), &collapsed); let full_count = full_rows.len(); // Find the first ScopeHeader's key let key = match &full_rows[0] { Row::ScopeHeader { section_key, .. } => section_key.clone(), _ => panic!("first row should be ScopeHeader"), }; collapsed.insert(key); let collapsed_rows = build_rows(&plan, &PathBuf::from("/tmp"), &collapsed); assert!( collapsed_rows.len() < full_count, "collapsed should have fewer rows" ); // The ScopeHeader itself should still be present assert!(matches!(collapsed_rows[0], Row::ScopeHeader { .. })); } }