//! Minimal-mode below-prompt **list panels**: `/resume` (session picker) and //! `/mcps` (MCP server status), rendered as simple lists *below the input bar* //! instead of centered modal windows (design nit: "the mcps / resume lists //! should not be in a modal"). //! //! ## Why this is a render-only change //! //! Input routing is unchanged — the existing `handle_modal_key` //! (`ActiveModal::SessionPicker`) and `handle_extensions_modal_key` //! (`extensions_modal`) own navigation and close-on-Esc. Two different coupling //! contracts are honored here: //! //! * **Session picker** rebuilds its entry map from data on every keypress //! (render-independent), so we just reuse the *same* builders //! ([`build_grouped_picker_entries`]) — the rendered order then matches the //! handler's `selected`. //! * **Extensions modal** reads render-stored state (`entry_data_indices`, //! `entry_group_keys`, `entry_non_selectable*`). The MCP renderer repopulates //! those exactly as the full modal does (via the shared //! [`build_mcp_servers_picker_rows`]), so keyboard nav + section fold stay in //! sync without touching the input handler. //! //! Both reuse [`picker::render_picker_content`] for the rows, so row look + //! selection highlight match the full TUI; only the modal-window chrome (border, //! tabs, footer bar) is dropped. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Span; use kigi_tui::app::agent_view::AgentView; use kigi_tui::minimal_api; use kigi_tui::theme::Theme; use kigi_tui::views::extensions_modal::{ExtensionsTab, TabDataState}; use kigi_tui::views::modal::ActiveModal; use kigi_tui::views::picker::{self, PickerEntry, PickerField, PickerHitAreas, PickerRow}; /// Rows of chrome around the scrolling list: title + subtitle/search + divider /// + footer. const CHROME_ROWS: u16 = 4; /// Which below-prompt list panel is active for the focused agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ListPanel { /// `/resume` session picker (`ActiveModal::SessionPicker`). Resume, /// `/mcps` MCP server status (extensions modal on the McpServers tab). Mcps, } /// Detect an active below-prompt list panel, or `None`. /// /// Only the session picker and the MCP-servers tab are hosted as simple lists; /// every other modal keeps its existing (centered) rendering. Callers must check /// this *before* `overlay::app_modal_active`, since `SessionPicker` is also an /// `active_modal`. pub(super) fn active(agent: &AgentView) -> Option { if matches!(agent.active_modal, Some(ActiveModal::SessionPicker { .. })) { return Some(ListPanel::Resume); } if minimal_api::extensions_modal(agent) .is_some_and(|s| s.active_tab == ExtensionsTab::McpServers) { return Some(ListPanel::Mcps); } None } /// Target viewport height for the active list panel: chrome + the exact body /// height, clamped to `[CHROME_ROWS + 1, ceiling]`. Sizing to the exact content /// height keeps the footer directly under the last row (no blank band); when the /// body exceeds `ceiling` the list scrolls internally. pub(super) fn panel_height(agent: &AgentView, kind: ListPanel, width: u16, ceiling: u16) -> u16 { let body = match kind { ListPanel::Resume => resume_body_rows(agent, width), ListPanel::Mcps => mcps_body_rows(agent), }; CHROME_ROWS .saturating_add(body) .clamp(CHROME_ROWS + 1, ceiling.max(CHROME_ROWS + 1)) } /// Render the active list panel into `area` (the whole live region). Returns the /// text cursor for the panel's search bar when search is focused, else `None`. pub(super) fn render( buf: &mut Buffer, area: Rect, agent: &mut AgentView, kind: ListPanel, theme: &Theme, ) -> Option<(u16, u16)> { if area.height < 2 || area.width < 8 { return None; } match kind { ListPanel::Resume => render_resume(buf, area, agent, theme), ListPanel::Mcps => render_mcps(buf, area, agent, theme), } } // ─────────────────────────────── chrome ───────────────────────────────────── /// Split `area` into (title_row, second_row, divider_row, list_area, footer_row). /// `second_row` hosts the subtitle (mcps) or the search bar (resume). fn chrome_layout(area: Rect) -> (Rect, Rect, Rect, Rect, Rect) { let row = |dy: u16| Rect { x: area.x, y: area.y + dy, width: area.width, height: 1, }; let title = row(0); let second = row(1); let divider = row(2); let footer = Rect { x: area.x, y: area.y + area.height - 1, ..row(0) }; let list = Rect { x: area.x, y: area.y + 3, width: area.width, height: area.height.saturating_sub(CHROME_ROWS), }; (title, second, divider, list, footer) } fn render_title(buf: &mut Buffer, row: Rect, theme: &Theme, title: &str) { buf.set_style(row, Style::default().bg(Color::Reset)); let style = Style::default() .fg(theme.accent_user) .bg(Color::Reset) .add_modifier(Modifier::BOLD); buf.set_span(row.x + 1, row.y, &Span::styled(title, style), row.width); } fn render_dim_line(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) { buf.set_style(row, Style::default().bg(Color::Reset)); let style = theme.dim().bg(Color::Reset); buf.set_span(row.x + 1, row.y, &Span::styled(text, style), row.width); } /// `/resume` session picker: Enter picks a session. const RESUME_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} enter confirm \u{00b7} esc cancel"; /// `/mcps` list: Enter expands tools; reconnect is space (off then on); `r` re-lists status. const MCPS_FOOTER: &str = "\u{2191}/\u{2193} navigate \u{00b7} space enable/disable \u{00b7} r refresh \u{00b7} enter expand \u{00b7} esc cancel"; fn render_footer(buf: &mut Buffer, row: Rect, theme: &Theme, text: &str) { render_dim_line(buf, row, theme, text); } fn render_divider(buf: &mut Buffer, row: Rect, theme: &Theme) { picker::render_divider(buf, row.x, row.y, row.width, theme, None); } // ─────────────────────────────── resume ───────────────────────────────────── /// Exact body height (display rows) for the session-picker list. fn resume_body_rows(agent: &AgentView, width: u16) -> u16 { let Some(ActiveModal::SessionPicker { entries, state, source_filter, .. }) = &agent.active_modal else { return 0; }; let entries_data = entries.as_deref().unwrap_or(&[]); let content_width = width.saturating_sub(2); let filtered = minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter); let built = minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width); let fields_vecs: Vec> = built .iter() .map(|b| { b.field_data .iter() .map(|(l, v)| PickerField { label: l, value: v }) .collect() }) .collect(); let current_repo = minimal_api::repo_name_from_cwd(&agent.session.cwd.to_string_lossy()); let (picker_entries, _) = minimal_api::build_grouped_picker_entries( entries_data, &filtered, &built, &fields_vecs, state, Some(current_repo.as_str()), ); measure_entries(&picker_entries) } fn render_resume( buf: &mut Buffer, area: Rect, agent: &mut AgentView, theme: &Theme, ) -> Option<(u16, u16)> { let cwd = agent.session.cwd.to_string_lossy().to_string(); let Some(ActiveModal::SessionPicker { entries, state, source_filter, .. }) = &mut agent.active_modal else { return None; }; let (title_row, search_row, divider_row, list_area, footer_row) = chrome_layout(area); let entries_data = entries.as_deref().unwrap_or(&[]); let content_width = area.width.saturating_sub(2); let filtered = minimal_api::filter_session_entries(entries.as_deref(), &state.query, *source_filter); let built = minimal_api::build_session_entry_data(entries_data, &filtered, state, content_width); let fields_vecs: Vec> = built .iter() .map(|b| { b.field_data .iter() .map(|(l, v)| PickerField { label: l, value: v }) .collect() }) .collect(); let current_repo = minimal_api::repo_name_from_cwd(&cwd); let (picker_entries, non_sel) = minimal_api::build_grouped_picker_entries( entries_data, &filtered, &built, &fields_vecs, state, Some(current_repo.as_str()), ); render_title(buf, title_row, theme, "Resume session"); // Focus-aware search bar (cursor only when search is focused). picker::render_search_bar( buf, search_row.x + 1, search_row.y, search_row.width.saturating_sub(1), theme, &state.query, state.search_active, true, state.query_cursor, None, ); render_divider(buf, divider_row, theme); let nsc = vec![false; picker_entries.len()]; let hit = picker::render_picker_content( buf, list_area, theme, state, &picker_entries, &non_sel, &nsc, None, false, ); state.hit_areas = Some(PickerHitAreas { close_button: Rect::default(), search_bar: search_row, item_rects: hit.item_rects, entry_indices: hit.entry_indices, tab_rects: vec![], filter_rect: None, }); render_footer(buf, footer_row, theme, RESUME_FOOTER); None } // ──────────────────────────────── mcps ────────────────────────────────────── /// Exact body height (display rows) for the MCP list: one line per row. fn mcps_body_rows(agent: &AgentView) -> u16 { let Some(s) = minimal_api::extensions_modal(agent) else { return 0; }; let servers = match &s.mcps_data { TabDataState::Loaded(v) => v.as_slice(), _ => return 1, // a single "loading…" / error row }; let rows = minimal_api::build_mcp_picker_rows( servers, &s.picker_state.query, s.mcps_filter, &s.mcps_collapsed_sections, &s.mcps_tools_expanded, ); rows.0.len() as u16 } fn render_mcps( buf: &mut Buffer, area: Rect, agent: &mut AgentView, theme: &Theme, ) -> Option<(u16, u16)> { let (title_row, subtitle_row, divider_row, list_area, footer_row) = chrome_layout(area); render_title(buf, title_row, theme, "Manage MCP servers"); // Phase 1 (immutable): build the row mapping + owned per-row render data. let labels: Vec; let group_keys: Vec>; let data_indices: Vec>; let badges: Vec; let badge_colors: Vec>; let right_labels: Vec; let indents: Vec; let collapsibles: Vec; let expandeds: Vec; let subtitle: String; let loading; { let s = minimal_api::extensions_modal(agent)?; let searching = !s.picker_state.query.is_empty(); loading = matches!(s.mcps_data, TabDataState::Loading); match &s.mcps_data { TabDataState::Loaded(servers) => { let (row_labels, row_group_keys, row_data_indices) = minimal_api::build_mcp_picker_rows( servers, &s.picker_state.query, s.mcps_filter, &s.mcps_collapsed_sections, &s.mcps_tools_expanded, ); let n = row_labels.len(); let mut b = vec![String::new(); n]; let mut bc: Vec> = vec![None; n]; let mut rl = vec![String::new(); n]; let mut ind = vec![0u8; n]; let mut col = vec![false; n]; let mut exp = vec![false; n]; for i in 0..n { let gk = row_group_keys[i].as_deref(); if gk.is_some_and(|k| k.starts_with("mcp-section:")) { col[i] = true; exp[i] = !minimal_api::mcp_section_children_hidden( &s.mcps_collapsed_sections, gk.unwrap(), searching, ); } else if gk.is_some_and(|k| k.starts_with("mcp-tools:")) { ind[i] = 1; col[i] = true; if let Some(si) = row_data_indices[i] { exp[i] = s.mcps_tools_expanded.contains(&si); if let Some(srv) = servers.get(si) { if !srv.enabled { b[i] = "disabled".to_string(); bc[i] = Some(theme.accent_error); } else { b[i] = minimal_api::mcp_status_label(&srv.status).to_string(); bc[i] = Some(minimal_api::mcp_status_theme_color( &srv.status, theme, )); } rl[i] = if srv.tool_count == 1 { "1 tool".to_string() } else { format!("{} tools", srv.tool_count) }; } } } else { ind[i] = 2; // tool child } } subtitle = format!( "{} server{}", servers.len(), if servers.len() == 1 { "" } else { "s" } ); labels = row_labels; group_keys = row_group_keys; data_indices = row_data_indices; badges = b; badge_colors = bc; right_labels = rl; indents = ind; collapsibles = col; expandeds = exp; } TabDataState::Loading => { subtitle = "loading\u{2026}".to_string(); labels = vec![]; group_keys = vec![]; data_indices = vec![]; badges = vec![]; badge_colors = vec![]; right_labels = vec![]; indents = vec![]; collapsibles = vec![]; expandeds = vec![]; } TabDataState::Error(msg) => { subtitle = format!("error: {msg}"); labels = vec![]; group_keys = vec![]; data_indices = vec![]; badges = vec![]; badge_colors = vec![]; right_labels = vec![]; indents = vec![]; collapsibles = vec![]; expandeds = vec![]; } } } let n = labels.len(); render_dim_line(buf, subtitle_row, theme, &subtitle); render_divider(buf, divider_row, theme); // Phase 2 (mutable): mirror the row mapping onto state for the input handler. { let s = minimal_api::extensions_modal_mut(agent)?; s.entry_data_indices = data_indices; s.entry_group_keys = group_keys; s.entry_labels_cache = labels.clone(); s.entry_non_selectable = vec![false; n]; s.entry_non_selectable_clickable = vec![false; n]; if n == 0 { s.picker_state.selected = 0; } else if s.picker_state.selected >= n { s.picker_state.selected = n - 1; } } // Phase 3 (mutable picker_state): build PickerEntry from owned data + render. let s = minimal_api::extensions_modal_mut(agent)?; let selected = s.picker_state.selected; let search_active = s.picker_state.search_active; let empty_fields: [PickerField; 0] = []; let no_lines: [&str; 0] = []; let entries: Vec = (0..n) .map(|i| { PickerEntry::Row(PickerRow { label: labels[i].as_str(), right_label: right_labels[i].as_str(), selected: !search_active && i == selected, expanded: expandeds[i], fields: &empty_fields, description_lines: &no_lines, summary_lines: &no_lines, dimmed: false, indent: indents[i], badge: badges[i].as_str(), badge_color: badge_colors[i], collapsible: collapsibles[i], underline_last_desc: false, }) }) .collect(); let non_sel = vec![false; n]; let hit = picker::render_picker_content( buf, list_area, theme, &mut s.picker_state, &entries, &non_sel, &non_sel, None, loading, ); s.picker_state.hit_areas = Some(PickerHitAreas { close_button: Rect::default(), search_bar: Rect::default(), item_rects: hit.item_rects, entry_indices: hit.entry_indices, tab_rects: vec![], filter_rect: None, }); render_footer(buf, footer_row, theme, MCPS_FOOTER); None } // ─────────────────────────────── helpers ──────────────────────────────────── /// Sum the display height of grouped picker entries: a header is one row; a row /// is its label line plus its collapsed summary lines (what the picker draws /// when the row is not expanded). fn measure_entries(entries: &[PickerEntry<'_>]) -> u16 { entries .iter() .map(|e| match e { PickerEntry::Header { .. } => 1u16, PickerEntry::Row(r) => { if r.expanded { 1u16.saturating_add(r.description_lines.len() as u16) .saturating_add(r.fields.len() as u16) } else { 1u16.saturating_add(r.summary_lines.len() as u16) } } }) .fold(0u16, |acc, h| acc.saturating_add(h)) } #[cfg(test)] mod tests { use super::*; use kigi_tui::views::extensions_modal::ExtensionsModalState; use kigi_tui::views::mcps_modal::{McpServerDisplayStatus, McpServerInfo, McpWireSource}; use ratatui::layout::Rect; fn agent() -> AgentView { minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp/repo")) } fn mcp_server(name: &str, status: McpServerDisplayStatus, tools: usize) -> McpServerInfo { McpServerInfo { name: name.to_string(), display_name: None, status, tool_count: tools, auth_required: false, tools: Vec::new(), enabled: true, source: "local".to_string(), wire_source: McpWireSource::Local, plugin_name: None, is_managed_gateway: false, } } fn with_mcps(servers: Vec) -> AgentView { let mut a = agent(); minimal_api::set_extensions_modal( &mut a, Some(ExtensionsModalState { active_tab: ExtensionsTab::McpServers, mcps_data: TabDataState::Loaded(servers), ..Default::default() }), ); a } fn session_entry(id: &str) -> kigi_tui::app::app_view::SessionPickerEntry { kigi_tui::app::app_view::SessionPickerEntry { id: id.into(), summary: id.into(), updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), cwd: "/tmp/repo".into(), hostname: None, source: String::new(), model_id: None, num_messages: 0, last_active_at: None, branch: None, repo_name: "repo".into(), worktree_label: None, card_detail: None, } } fn with_resume(entries: Vec) -> AgentView { let mut a = agent(); a.active_modal = Some(ActiveModal::SessionPicker { state: picker::PickerState::default(), entries: Some(entries), loading: false, lanes: Default::default(), previous_palette: None, window: kigi_tui::views::modal_window::ModalWindowState::new(), content_results: None, content_loading: false, deep_search_seq: 0, source_filter: kigi_tui::views::session_picker::SourceFilter::default(), pending_delete: None, entries_query: None, }); a } fn buffer_text(buf: &Buffer) -> String { let area = buf.area; let mut out = String::new(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { out.push_str(buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")); } out.push('\n'); } out } #[test] fn active_detects_resume_mcps_and_none() { assert_eq!(active(&agent()), None); assert_eq!( active(&with_mcps(vec![mcp_server( "alpha", McpServerDisplayStatus::Ready, 3 )])), Some(ListPanel::Mcps) ); assert_eq!( active(&with_resume(vec![session_entry("hello")])), Some(ListPanel::Resume) ); } #[test] fn mcps_panel_renders_list_and_mirrors_handler_state() { let mut a = with_mcps(vec![ mcp_server("alpha", McpServerDisplayStatus::Ready, 3), mcp_server("bravo", McpServerDisplayStatus::Unavailable, 0), ]); let theme = Theme::current(); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); render(&mut buf, area, &mut a, ListPanel::Mcps, &theme); let text = buffer_text(&buf); assert!(text.contains("Manage MCP servers"), "title:\n{text}"); assert!(text.contains("2 servers"), "subtitle:\n{text}"); assert!(text.contains("alpha"), "server row:\n{text}"); assert!(text.contains("bravo"), "server row:\n{text}"); assert!(text.contains("space enable/disable"), "footer:\n{text}"); assert!(text.contains("r refresh"), "footer:\n{text}"); assert!(text.contains("enter expand"), "footer:\n{text}"); assert!( !text.contains("enter confirm"), "MCP footer must not reuse resume confirm copy:\n{text}" ); // The input handler reads these render-stored fields; the panel must // mirror them (section header + 2 servers = 3 rows) so keyboard nav and // fold stay correct without touching the handler. let s = minimal_api::extensions_modal(&a).unwrap(); assert_eq!(s.entry_data_indices.len(), 3, "section + 2 servers"); assert_eq!( s.entry_data_indices.iter().filter(|d| d.is_some()).count(), 2, "two selectable server rows map to catalog indices" ); assert_eq!(s.entry_non_selectable.len(), 3); } #[test] fn resume_panel_renders_title_rows_and_footer() { let mut a = with_resume(vec![session_entry("first task"), session_entry("second")]); let theme = Theme::current(); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); render(&mut buf, area, &mut a, ListPanel::Resume, &theme); let text = buffer_text(&buf); assert!(text.contains("Resume session"), "title:\n{text}"); assert!(text.contains("first task"), "session row:\n{text}"); assert!(text.contains("enter confirm"), "resume footer:\n{text}"); assert!( !text.contains("r refresh"), "resume footer must stay session-picker copy:\n{text}" ); } #[test] fn mcps_panel_height_is_chrome_plus_rows() { // One section header + 2 server rows = 3 body rows; + 4 chrome = 7. let a = with_mcps(vec![ mcp_server("alpha", McpServerDisplayStatus::Ready, 1), mcp_server("bravo", McpServerDisplayStatus::Ready, 2), ]); assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 40), 7); // Clamps to the screen ceiling when content is taller. assert_eq!(panel_height(&a, ListPanel::Mcps, 80, 5), 5); } }