Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4c804a2
feat(tui): add selection_mode_active field to App
fulsomenko Feb 17, 2026
a9194de
feat(tui): add SetMultipleCardsPriority dialog mode
fulsomenko Feb 17, 2026
7b36b5d
feat(tui): add keybinding actions for multi-select operations
fulsomenko Feb 17, 2026
bdf0cb2
feat(tui): add BulkPriorityDialog component
fulsomenko Feb 17, 2026
081cad4
feat(tui): register bulk priority dialog provider
fulsomenko Feb 17, 2026
fc14bd9
feat(tui): add card list keybindings for bulk operations
fulsomenko Feb 17, 2026
f578564
feat(tui): add card selection handler functions
fulsomenko Feb 17, 2026
000e809
feat(tui): add bulk move for selected cards
fulsomenko Feb 17, 2026
18cb462
feat(tui): implement vim-style selection mode toggle
fulsomenko Feb 17, 2026
43423ec
feat(tui): add auto-select on navigation in selection mode
fulsomenko Feb 17, 2026
ffd863a
feat(tui): update escape handler for selection mode
fulsomenko Feb 17, 2026
17a3fbb
feat(tui): add bulk priority popup handler
fulsomenko Feb 17, 2026
0e54100
feat(tui): wire keybinding actions in execute_action
fulsomenko Feb 17, 2026
250f5f1
feat(tui): add keyboard shortcuts for multi-select
fulsomenko Feb 17, 2026
89c2b78
feat(tui): handle SetMultipleCardsPriority dialog in event loop
fulsomenko Feb 17, 2026
0b5c9d4
feat(tui): add selection mode indicator to footer
fulsomenko Feb 17, 2026
7673653
feat(tui): add bulk priority popup rendering
fulsomenko Feb 17, 2026
02060fd
chore: add changeset
fulsomenko Feb 17, 2026
a87330f
refactor(tui): extract PRIORITY_COUNT constant
fulsomenko Feb 17, 2026
3b64767
fix(tui): only intercept escape when in selection mode
fulsomenko Feb 17, 2026
b7987e3
fix(tui): clear selection_mode_active after bulk operations
fulsomenko Feb 17, 2026
3271b2e
docs(tui): document multi-select and bulk operations
fulsomenko Feb 18, 2026
d7553e5
docs: add bulk operations to usage section
fulsomenko Feb 18, 2026
ee48b93
fix(tui): activate selection mode when selecting all cards
fulsomenko Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/kan-209-multi-select-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
bump: patch
---

- feat(tui): add bulk priority popup rendering
- feat(tui): add selection mode indicator to footer
- feat(tui): handle SetMultipleCardsPriority dialog in event loop
- feat(tui): add keyboard shortcuts for multi-select
- feat(tui): wire keybinding actions in execute_action
- feat(tui): add bulk priority popup handler
- feat(tui): update escape handler for selection mode
- feat(tui): add auto-select on navigation in selection mode
- feat(tui): implement vim-style selection mode toggle
- feat(tui): add bulk move for selected cards
- feat(tui): add card selection handler functions
- feat(tui): add card list keybindings for bulk operations
- feat(tui): register bulk priority dialog provider
- feat(tui): add BulkPriorityDialog component
- feat(tui): add keybinding actions for multi-select operations
- feat(tui): add SetMultipleCardsPriority dialog mode
- feat(tui): add selection_mode_active field to App
25 changes: 25 additions & 0 deletions crates/kanban-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub struct App {
pub current_sort_field: Option<SortField>,
pub current_sort_order: Option<SortOrder>,
pub selected_cards: std::collections::HashSet<uuid::Uuid>,
pub selection_mode_active: bool,
pub priority_selection: SelectionState,
pub column_selection: SelectionState,
pub task_list_view_selection: SelectionState,
Expand Down Expand Up @@ -146,6 +147,7 @@ pub enum DialogMode {
ImportBoard,
SetCardPoints,
SetCardPriority,
SetMultipleCardsPriority,
SetBranchPrefix,
OrderCards,
CreateSprint,
Expand Down Expand Up @@ -213,6 +215,7 @@ impl App {
current_sort_field: None,
current_sort_order: None,
selected_cards: std::collections::HashSet::new(),
selection_mode_active: false,
priority_selection: SelectionState::new(),
column_selection: SelectionState::new(),
task_list_view_selection: SelectionState::new(),
Expand Down Expand Up @@ -422,6 +425,9 @@ impl App {
KeybindingAction::ToggleArchivedView => self.handle_toggle_archived_cards_view(),
KeybindingAction::ToggleTaskListView => self.handle_toggle_task_list_view(),
KeybindingAction::ToggleCardSelection => self.handle_card_selection_toggle(),
KeybindingAction::ClearCardSelection => self.handle_clear_card_selection(),
KeybindingAction::SelectAllCards => self.handle_select_all_cards_in_view(),
KeybindingAction::SetSelectedCardsPriority => self.handle_set_selected_cards_priority(),
KeybindingAction::Search => {
if self.focus == Focus::Cards {
self.search.activate();
Expand Down Expand Up @@ -510,6 +516,18 @@ impl App {
return false;
}

// Handle Ctrl+a for select all cards
if matches!(self.mode, AppMode::Normal)
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('a'))
{
self.pending_key = None;
self.handle_select_all_cards_in_view();
return false;
}

match self.mode {
AppMode::Normal => match key.code {
KeyCode::Char('/') => {
Expand Down Expand Up @@ -618,6 +636,10 @@ impl App {
self.pending_key = None;
self.handle_toggle_task_list_view();
}
KeyCode::Char('P') => {
self.pending_key = None;
self.handle_set_selected_cards_priority();
}
KeyCode::Char('H') => {
self.pending_key = None;
self.handle_move_card_left();
Expand Down Expand Up @@ -726,6 +748,9 @@ impl App {
should_restart_events = self.handle_set_card_points_dialog(key.code);
}
DialogMode::SetCardPriority => self.handle_set_card_priority_popup(key.code),
DialogMode::SetMultipleCardsPriority => {
self.handle_set_multiple_cards_priority_popup(key.code)
}
DialogMode::SetBranchPrefix => self.handle_set_branch_prefix_dialog(key.code),
DialogMode::SetSprintPrefix => self.handle_set_sprint_prefix_dialog(key.code),
DialogMode::SetSprintCardPrefix => {
Expand Down
50 changes: 50 additions & 0 deletions crates/kanban-tui/src/components/selection_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,56 @@ impl SelectionDialog for PriorityDialog {
}
}

pub struct BulkPriorityDialog {
pub count: usize,
}

impl SelectionDialog for BulkPriorityDialog {
fn title(&self) -> &str {
"Set Priority (Bulk)"
}

fn get_current_selection(&self, _app: &App) -> usize {
0
}

fn options_count(&self, _app: &App) -> usize {
4 // Low, Medium, High, Critical
}

fn render(&self, app: &App, frame: &mut Frame) {
use crate::components::render_selection_popup_with_list_items;
use crate::theme::*;
use kanban_domain::CardPriority;
use ratatui::widgets::ListItem;

let priorities = [
CardPriority::Low,
CardPriority::Medium,
CardPriority::High,
CardPriority::Critical,
];

let selected = app.priority_selection.get();

let items: Vec<ListItem> = priorities
.iter()
.enumerate()
.map(|(idx, priority)| {
let style = if Some(idx) == selected {
bold_highlight()
} else {
normal_text()
};
ListItem::new(format!("{:?}", priority)).style(style)
})
.collect();

let title = format!("Set Priority ({} cards)", self.count);
render_selection_popup_with_list_items(frame, &title, items, 35, 40);
}
}

pub struct SortFieldDialog;

impl SelectionDialog for SortFieldDialog {
Expand Down
115 changes: 109 additions & 6 deletions crates/kanban-tui/src/handlers/card_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,44 @@ impl App {

pub fn handle_card_selection_toggle(&mut self) {
if self.focus == Focus::Cards {
if let Some(card) = self.get_selected_card_in_context() {
let card_id = card.id;
if self.selected_cards.contains(&card_id) {
self.selected_cards.remove(&card_id);
} else {
self.selected_cards.insert(card_id);
if self.selection_mode_active {
// Exit selection mode (keep selections)
self.selection_mode_active = false;
} else {
// Enter selection mode and select current card
self.selection_mode_active = true;
if let Some(card) = self.get_selected_card_in_context() {
self.selected_cards.insert(card.id);
}
}
}
}

pub fn handle_clear_card_selection(&mut self) {
self.selected_cards.clear();
}

pub fn handle_select_all_cards_in_view(&mut self) {
if self.focus != Focus::Cards {
return;
}

if let Some(task_list) = self.view_strategy.get_active_task_list() {
for card_id in &task_list.cards {
self.selected_cards.insert(*card_id);
}
}
}

pub fn handle_set_selected_cards_priority(&mut self) {
if self.focus != Focus::Cards || self.selected_cards.is_empty() {
return;
}

self.priority_selection.set(Some(0));
self.open_dialog(DialogMode::SetMultipleCardsPriority);
}

pub fn handle_assign_to_sprint_key(&mut self) {
if self.focus != Focus::Cards {
return;
Expand Down Expand Up @@ -396,6 +423,11 @@ impl App {
return;
}

if !self.selected_cards.is_empty() {
self.move_selected_cards(direction);
return;
}

if let Some(card) = self.get_selected_card_in_context() {
let board = self
.active_board_index
Expand Down Expand Up @@ -479,6 +511,77 @@ impl App {
}
}

fn move_selected_cards(&mut self, direction: kanban_domain::card_lifecycle::MoveDirection) {
let board = self
.active_board_index
.and_then(|idx| self.ctx.boards.get(idx));
let board = match board {
Some(b) => b,
None => return,
};

let card_ids: Vec<uuid::Uuid> = self.selected_cards.iter().copied().collect();
let first_card_id = card_ids.first().copied();
let mut commands: Vec<Box<dyn kanban_domain::commands::Command>> = Vec::new();
let mut moved_count = 0;

for card_id in &card_ids {
let card = match self.ctx.cards.iter().find(|c| c.id == *card_id) {
Some(c) => c,
None => continue,
};

let move_result = kanban_domain::card_lifecycle::compute_card_column_move(
card,
board,
&self.ctx.columns,
&self.ctx.cards,
direction,
);

let move_result = match move_result {
Some(r) => r,
None => continue,
};

commands.push(Box::new(MoveCard {
card_id: *card_id,
new_column_id: move_result.target_column_id,
new_position: move_result.new_position,
}));

if let Some(new_status) = move_result.new_status {
commands.push(Box::new(UpdateCard {
card_id: *card_id,
updates: CardUpdate {
status: Some(new_status),
..Default::default()
},
}));
}

moved_count += 1;
}

if !commands.is_empty() {
if let Err(e) = self.execute_commands_batch(commands) {
let dir = match direction {
kanban_domain::card_lifecycle::MoveDirection::Left => "left",
kanban_domain::card_lifecycle::MoveDirection::Right => "right",
};
tracing::error!("Failed to move cards {}: {}", dir, e);
return;
}
}

tracing::info!("Moved {} cards", moved_count);
self.selected_cards.clear();
self.refresh_view();
if let Some(card_id) = first_card_id {
self.select_card_by_id(card_id);
}
}

pub fn handle_archive_card(&mut self) {
if self.focus != Focus::Cards {
return;
Expand Down
21 changes: 21 additions & 0 deletions crates/kanban-tui/src/handlers/navigation_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,13 @@ impl App {
}
}

// If selection mode active, add the new current card to selection
if self.selection_mode_active {
if let Some(card) = self.get_selected_card_in_context() {
self.selected_cards.insert(card.id);
}
}

// Check for bottom navigation: only switch columns if we were ALREADY at bottom
if was_at_bottom {
self.view_strategy.navigate_right(false);
Expand Down Expand Up @@ -419,6 +426,13 @@ impl App {
}
}

// If selection mode active, add the new current card to selection
if self.selection_mode_active {
if let Some(card) = self.get_selected_card_in_context() {
self.selected_cards.insert(card.id);
}
}

// Check for top navigation: only switch columns if we were ALREADY at top
if was_at_top {
self.view_strategy.navigate_left(true);
Expand Down Expand Up @@ -482,6 +496,13 @@ impl App {
}

pub fn handle_escape_key(&mut self) {
// Clear selection mode and selections first
if self.selection_mode_active || !self.selected_cards.is_empty() {
self.selection_mode_active = false;
self.selected_cards.clear();
return;
}

if self.active_board_index.is_some() {
self.active_board_index = None;
self.focus = Focus::Boards;
Expand Down
Loading