Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1444 lines
54 KiB
Rust
1444 lines
54 KiB
Rust
//! 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<bool>,
|
|
/// 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<ratatui::layout::Rect>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<usize>,
|
|
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<usize>,
|
|
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<Vec<usize>> = 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<Vec<usize>> = 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<String>,
|
|
) -> Vec<Row> {
|
|
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<Row>,
|
|
flat_index: &mut usize,
|
|
scope: Scope,
|
|
items: &[ImportableItem],
|
|
collapsed: &std::collections::HashSet<String>,
|
|
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<ItemKind> = None;
|
|
let mut header_pos: Option<usize> = None;
|
|
let mut group_indices: Vec<usize> = 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<String>,
|
|
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<usize> {
|
|
rows.iter()
|
|
.enumerate()
|
|
.skip(from + 1)
|
|
.find(|(_, r)| is_selectable(r))
|
|
.map(|(i, _)| i)
|
|
}
|
|
|
|
fn prev_selectable_row(rows: &[Row], from: usize) -> Option<usize> {
|
|
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 { .. }));
|
|
}
|
|
}
|