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

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. The handle_events() function serves as the primary entry point, polling crossterm and delegating to context-specific handlers based on the current InputMode.

// 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:

// 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.

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.

// 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
// 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));
// 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, 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →