# How Crossterm Keybindings Drive Filter, Sort, and Navigation State Changes in llmfit

> Learn how crossterm keybindings in llmfit TUI control filter, sort, and navigation state changes. Discover event handling and app struct mutation for seamless updates.

- Repository: [Alex Jones/llmfit](https://github.com/AlexsJones/llmfit)
- Tags: internals
- Published: 2026-09-11

---

**In the llmfit TUI, crossterm captures raw key events in `handle_events()` and routes them through mode-specific handlers like `handle_normal_mode()`, which invoke methods such as `cycle_fit_filter()` and `move_down()` to mutate the `App` struct's filter enums, sort columns, and navigation indices before ratatui renders the updated viewport.**

The llmfit project implements an interactive terminal interface using **crossterm** for cross-platform input handling and **ratatui** for immediate-mode rendering. Every keyboard interaction flows through a centralized dispatch system that maps physical key codes to semantic state operations, ensuring a clear separation between input detection, state mutation, and visual presentation.

## Central Event Dispatch Architecture

All keyboard input enters the application through the event loop defined in [`llmfit-tui/src/tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_events.rs). The `handle_events()` function serves as the primary entry point, polling crossterm and delegating to context-specific handlers based on the current `InputMode`.

```rust
// llmfit-tui/src/tui_events.rs
pub fn handle_events(app: &mut App) -> std::io::Result<bool> {
    let processed = handle_pending_events(app)?;
    // Resize handling omitted for brevity
    Ok(processed)
}

```

The `handle_pending_events()` function reads `crossterm::event::read()` and filters for `KeyEventKind::Press` events. After confirming a key press, it matches the application's current mode and dispatches to the appropriate handler:

```rust
// llmfit-tui/src/tui_events.rs
match app.input_mode {
    InputMode::Normal => handle_normal_mode(app, key),
    InputMode::Visual => handle_visual_mode(app, key),
    // Additional modes omitted
}

```

This architecture ensures that navigation, filtering, and sorting logic remains isolated within `handle_normal_mode()` while the core loop remains agnostic to specific key semantics.

## Navigation Keybindings and State Mutations

When operating in `Normal` mode, the `handle_normal_mode()` function maps specific keys to navigation methods on the `App` struct. These methods update `selected_row` and synchronize the `TableState` to control the visible viewport.

- **`j`** or **`↓`** invokes `app.move_down()`, incrementing `selected_row` if it remains within `filtered_fits.len()`.
- **`k`** or **`↑`** invokes `app.move_up()`, decrementing `selected_row` when greater than zero.
- **`PageDown`** triggers `app.page_down()` while **`PageUp`** triggers `app.page_up()` for rapid vertical traversal.
- **`Home`** or **`g`** calls `app.cycle_top_bottom()` to jump between the first and last entries.
- **`Ctrl-d`** and **`Ctrl-u`** execute `app.half_page_down()` and `app.half_page_up()` respectively, scrolling the viewport by half its height.

After any navigation call, the handler invokes `update_model_viewport(app, terminal_area)` to recalculate scroll offsets and ensure the selected row remains visible. The `move_up()` and `move_down()` methods also call `self.update_table_state()`, which executes `self.table_state.select(Some(self.selected_row))` to notify the ratatui table widget of the selection change.

## Filter and Sort Keybindings

The TUI provides single-key toggles for cycling through enumeration-based filters and sort criteria. Each keypress mutates the corresponding field in the `App` struct and triggers the filter pipeline.

| Key | Handler Method | State Change |
|-----|----------------|--------------|
| **`f`** | `cycle_fit_filter()` | Rotates `FitFilter` through `All`, `Runnable`, `Perfect`, etc., then calls `apply_filters()`. |
| **`a`** | `cycle_availability_filter()` | Cycles `AvailabilityFilter` between `All`, `GgufAvailable`, and `Installed`. |
| **`T`** | `cycle_tp_filter()` | Rotates the tensor-parallelism filter through `All`, `Tp2`, `Tp3`, and `Tp4`. |
| **`s`** | `cycle_sort_column()` | Switches `sort_column` between variants like `Score`, `TokensPerSecond`, and `Parameters`, toggling `sort_ascending` as needed. |
| **`i`** | `toggle_installed_first()` | Flips the boolean flag that prioritizes installed models in the sort order. |
| **`F`** | `open_filter_popup()` | Transitions to `InputMode::FilterPopup`, allowing granular numeric range editing. |

All filter cyclers follow the same pattern: update the enum variant, reset `selected_row` to zero, and invoke `apply_filters()`. This method iterates over `app.all_fits` and rebuilds `app.filtered_fits` based on the active predicate set. The `cycle_sort_column()` method similarly triggers a re-sort of the filtered dataset according to the new column and direction.

## State Propagation Pipeline

The mutation flow follows a strict unidirectional pattern from input to render. When a keybinding activates:

1. **Input Handling**: `handle_normal_mode()` receives the `KeyEvent` and calls the appropriate mutator.
2. **State Mutation**: Methods like `cycle_fit_filter()` modify enum fields and invoke `apply_filters()`, which repopulates `filtered_fits` and resets the table offset.
3. **Viewport Synchronization**: Navigation handlers update `table_state` and call `update_model_viewport()` to adjust the scroll window.
4. **Rendering**: The main loop calls `terminal.draw(|f| crate::tui_ui::draw(f, &mut app))`, where `draw()` reads the updated `App` state—including `table_state`, `filtered_fits`, and active filters—to compose the frame.

This design maintains **stateless rendering**; the `draw()` function never modifies `App`, ensuring deterministic output based solely on the current state snapshot.

## Practical Code Examples

The following snippets demonstrate the exact handler invocations used internally when specific keys are pressed.

```rust
// Simulate pressing "j" (move down)
let mut app = App::default();
app.input_mode = InputMode::Normal;
handle_normal_mode(&mut app, KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.selected_row, 1); // Moves to second item

```

```rust
// Cycle sort column by pressing "s"
handle_normal_mode(&mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
// First press typically moves from Score to TokensPerSecond
assert!(matches!(app.sort_column, SortColumn::TokensPerSecond));

```

```rust
// Open filter popup and edit minimum parameters
handle_normal_mode(&mut app, KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE));
app.filter_field = FilterPopupField::ParamsMin;
// Simulate typing "10" and confirming
handle_filter_popup_mode(&mut app, KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
handle_filter_popup_mode(&mut app, KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE));
handle_filter_popup_mode(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

```

## Summary

- **Centralized Dispatch**: All crossterm events flow through `handle_events()` in [`llmfit-tui/src/tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/tui_events.rs), which delegates to `handle_normal_mode()` for standard interactions.
- **Navigation**: Keys like `j`, `k`, `PageUp`, and `Ctrl-d` invoke `move_down()`, `move_up()`, and `half_page_up()` to update `selected_row` and `table_state`.
- **Filtering**: Single-key toggles (`f`, `a`, `T`) cycle enum filters and trigger `apply_filters()` to rebuild the visible dataset from `all_fits`.
- **Sorting**: The `s` key cycles `sort_column` and `sort_ascending`, causing immediate re-sorting of `filtered_fits`.
- **Stateless Render**: The `draw()` function in `tui_ui` consumes the mutated `App` state without side effects, ensuring the TUI reflects the exact filter, sort, and navigation state.

## Frequently Asked Questions

### How does pressing `f` change the model list in llmfit?

Pressing **`f`** in normal mode calls `cycle_fit_filter()` on the `App` struct, which rotates the `FitFilter` enum to the next variant (e.g., from `All` to `Runnable`). This mutation immediately triggers `apply_filters()`, which iterates through `all_fits` and retains only entries matching the new filter criteria. The `filtered_fits` vector is rebuilt, `selected_row` resets to zero, and the table viewport updates to show the reduced dataset.

### What happens to the internal state when I navigate with `j` and `k`?

The **`j`** and **`k`** keys invoke `move_down()` and `move_up()` respectively. These methods increment or decrement `app.selected_row` within bounds, then call `update_table_state()` to sync the ratatui `TableState` selection. Finally, `update_model_viewport()` calculates the necessary scroll offset to keep the selected row visible, ensuring the UI state remains consistent with the navigation commands.

### Can filter and sort configurations persist between llmfit sessions?

Yes. The `FilterConfig` struct in [`llmfit-tui/src/filter_config.rs`](https://github.com/AlexsJones/llmfit/blob/main/llmfit-tui/src/filter_config.rs) serializes the current `FitFilter`, `AvailabilityFilter`, `TpFilter`, `SortColumn`, and sort direction to disk. When the TUI launches, it deserializes this configuration back into the `App` struct before the first render, preserving user preferences across restarts without requiring manual reconfiguration.

### Why does the filter popup require an explicit confirmation?

When you press **`F`** to open the filter popup, the TUI enters `InputMode::FilterPopup`, creating a temporary editing context for numeric ranges. The modal maintains separate input buffers (like `filter_params_min_input`) to allow cancellation. Pressing **Enter** calls `apply_filter_popup()`, which validates and copies these temporary values into the main `App` filter fields before invoking `apply_filters()`. This pattern prevents partial or invalid filter states from corrupting the main dataset during editing.