Skip to main content
The multiselect package provides an interactive terminal user interface (TUI) component built with Bubble Tea for selecting multiple items from a list. It’s used throughout Pumu for interactive folder selection in sweep and reinstall operations.

Location

internal/ui/multiselect.go

Core Types

Item

Represents a selectable item in the multi-select list.
type Item struct {
    Label    string  // Primary text (e.g., folder path)
    Detail   string  // Secondary text (e.g., formatted size)
    Selected bool    // Selection state
}
Fields:
  • Label - The main display text for the item
  • Detail - Optional supplementary information displayed alongside the label
  • Selected - Whether the item is currently selected

Result

Holds the outcome of the multi-select interaction.
type Result struct {
    Items    []Item  // All items with updated selection states
    Canceled bool    // True if user canceled (q/esc)
}
Fields:
  • Items - The complete list of items with their final selection states
  • Canceled - Indicates whether the user canceled the operation instead of confirming

model

Internal Bubble Tea model (unexported). Implements the tea.Model interface.
type model struct {
    title    string
    items    []Item
    cursor   int
    showHelp bool
    done     bool
    canceled bool
}
Fields:
  • title - Header text displayed at the top
  • items - The list of selectable items
  • cursor - Current cursor position (0-based index)
  • showHelp - Whether the help menu is currently visible
  • done - Whether the interaction is complete
  • canceled - Whether the user canceled

Public API

RunMultiSelect

Launches an interactive multi-select prompt and returns the result.
func RunMultiSelect(title string, items []Item) (Result, error)
Parameters:
  • title - The header text shown at the top of the selection UI
  • items - Slice of items to display. Pre-set Selected: true for default selections
Returns:
  • Result - Contains the items with updated selection states and cancellation status
  • error - Returns error if the Bubble Tea program fails to run
Behavior:
  • All items passed in with Selected: true will be pre-selected by default
  • Blocks until user confirms (Enter) or cancels (q/Esc)
  • Returns immediately if an error occurs

Bubble Tea Implementation

Init

func (m model) Init() tea.Cmd
Initializes the model. Returns nil (no initial commands).

Update

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
Handles incoming messages and updates the model state. Processes:
  • Keyboard input via handleKeyMsg()
  • Returns tea.Quit command when done

View

func (m model) View() string
Renders the current state as a string for terminal display. Rendering includes:
  1. Title - Bold, purple header
  2. Item list - Each item shows:
    • Cursor indicator ( for current item)
    • Checkbox ([✓] for selected, [ ] for unselected)
    • Label text (highlighted when cursor is on it)
    • Detail text in blue (if present)
  3. Status bar - Shows X/Y selected count
  4. Help text - Shows press ? for help or full keyboard shortcuts when toggled

Keyboard Shortcuts

KeyActionDescription
/ kMove upMove cursor to previous item
/ jMove downMove cursor to next item
gGo to firstJump to first item in list
GGo to lastJump to last item in list
spaceToggle itemToggle selection state of current item
aSelect allSelect all items in the list
nDeselect allClear all selections
iInvert selectionToggle selection state of all items
enterConfirmAccept current selections and exit
q / esc / ctrl+cCancelExit without saving changes
?Toggle helpShow/hide full keyboard shortcuts

Message Handling

handleKeyMsg

func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd)
Processes keyboard input and routes to appropriate handlers:
  • Navigation keys - Delegates to handleNavigation()
  • Selection keys - Delegates to handleSelection()
  • Help toggle - ? key toggles help display
  • Confirm - enter sets done = true and quits
  • Cancel - q, esc, or ctrl+c sets both canceled and done, then quits

handleNavigation

func (m *model) handleNavigation(key string)
Updates cursor position based on navigation keys:
  • up / k - Decrement cursor (bounded at 0)
  • down / j - Increment cursor (bounded at list length)
  • home / g - Set cursor to 0
  • end / G - Set cursor to last item

handleSelection

func (m *model) handleSelection(key string)
Modifies item selection states:
  • space - Toggle current item
  • a - Set all items to selected
  • n - Clear all selections
  • i - Invert all selections

Visual Styling

The component uses Lipgloss for styling:
ElementColorStyle
TitlePurple #7C3AEDBold
CursorPurple #7C3AEDBold
Selected checkboxGreen #22C55EBold
Unselected checkboxGray #6B7280Regular
Active item labelWhite #E5E7EBRegular
Inactive item labelGray #9CA3AFRegular
Detail textBlue #60A5FARegular
Status barPurple #A78BFARegular
Help keyPurple #A78BFABold
Help descriptionGray #9CA3AFRegular

Usage Example

Basic Usage (from sweep command)

package scanner

import (
    "pumu/internal/ui"
    "fmt"
)

// Present interactive selection for deletion
func selectFolders(folders []TargetFolder, title string) ([]TargetFolder, error) {
    // Convert to UI items
    items := make([]ui.Item, len(folders))
    for i, f := range folders {
        items[i] = ui.Item{
            Label:    f.Path,
            Detail:   formatSize(f.Size),  // e.g., "1.23 GB"
            Selected: true,                 // Pre-select all by default
        }
    }

    // Launch interactive selection
    result, err := ui.RunMultiSelect(title, items)
    if err != nil {
        return nil, err
    }

    // Handle cancellation
    if result.Canceled {
        return nil, nil
    }

    // Extract selected folders
    var selected []TargetFolder
    for i, item := range result.Items {
        if item.Selected {
            selected = append(selected, folders[i])
        }
    }

    return selected, nil
}

Reinstall Selection (from sweep —reinstall)

// Interactive selection for reinstallation
items := make([]ui.Item, len(targets))
for i, t := range targets {
    items[i] = ui.Item{
        Label:    t.Dir,               // e.g., "/home/user/webapp"
        Detail:   string(t.PM),        // e.g., "npm"
        Selected: true,
    }
}

result, err := ui.RunMultiSelect("📦 Select projects to reinstall:", items)
if err != nil {
    return fmt.Errorf("selection failed: %w", err)
}

if result.Canceled {
    fmt.Println("Reinstallation canceled")
    return nil
}

// Process selected projects
for i, item := range result.Items {
    if item.Selected {
        installDependencies(targets[i].Dir, targets[i].PM)
    }
}

Integration Points

The multi-select component is used in:
  1. Sweep command (scanner.SweepDir)
    • Selecting which folders to delete
    • Selecting which projects to reinstall (when --reinstall is used)
  2. Command-line flow
    • pumu sweep - Interactive folder deletion selection
    • pumu sweep --reinstall - Two-stage selection (delete + reinstall)
    • pumu sweep --no-select - Bypasses multi-select entirely

Error Handling

result, err := ui.RunMultiSelect(title, items)
if err != nil {
    // Handle Bubble Tea runtime error
    return fmt.Errorf("failed to run multi-select: %w", err)
}

if result.Canceled {
    // User pressed q/esc - treat as graceful cancellation
    return nil
}

// Process result.Items normally

Implementation Notes

  • Thread-safe - Model is isolated within Bubble Tea’s event loop
  • No external state - All state is contained in the model struct
  • Keyboard-driven - No mouse support
  • Accessible - Uses standard terminal conventions (arrow keys, vi keys, etc.)
  • Minimal dependencies - Only requires Bubble Tea and Lipgloss
  • Single-use - Each RunMultiSelect() call creates a fresh program instance

See Also

Build docs developers (and LLMs) love