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.
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.
jor↓invokesapp.move_down(), incrementingselected_rowif it remains withinfiltered_fits.len().kor↑invokesapp.move_up(), decrementingselected_rowwhen greater than zero.PageDowntriggersapp.page_down()whilePageUptriggersapp.page_up()for rapid vertical traversal.Homeorgcallsapp.cycle_top_bottom()to jump between the first and last entries.Ctrl-dandCtrl-uexecuteapp.half_page_down()andapp.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:
- Input Handling:
handle_normal_mode()receives theKeyEventand calls the appropriate mutator. - State Mutation: Methods like
cycle_fit_filter()modify enum fields and invokeapply_filters(), which repopulatesfiltered_fitsand resets the table offset. - Viewport Synchronization: Navigation handlers update
table_stateand callupdate_model_viewport()to adjust the scroll window. - Rendering: The main loop calls
terminal.draw(|f| crate::tui_ui::draw(f, &mut app)), wheredraw()reads the updatedAppstate—includingtable_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()inllmfit-tui/src/tui_events.rs, which delegates tohandle_normal_mode()for standard interactions. - Navigation: Keys like
j,k,PageUp, andCtrl-dinvokemove_down(),move_up(), andhalf_page_up()to updateselected_rowandtable_state. - Filtering: Single-key toggles (
f,a,T) cycle enum filters and triggerapply_filters()to rebuild the visible dataset fromall_fits. - Sorting: The
skey cyclessort_columnandsort_ascending, causing immediate re-sorting offiltered_fits. - Stateless Render: The
draw()function intui_uiconsumes the mutatedAppstate 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →