feat(tui): add vim-like keybinds and settings tab for config edit
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled

This commit is contained in:
uttarayan21
2025-10-08 16:11:41 +05:30
parent 48e26332a3
commit a8f0ab160e
6 changed files with 660 additions and 55 deletions

View File

@@ -2,13 +2,17 @@
A Terminal User Interface (TUI) for managing Sonarr.
> ✨ **Note**: This project was fully vibe coded with AI assistance, showcasing modern development workflows and comprehensive feature implementation.
## Features
- View system status and health
- Browse series and episodes
- Monitor download queue
- View download history
- Interactive TUI interface
- Interactive TUI interface with vim-like keybind support
- Configurable UI preferences and keybind modes
- In-app configuration editing for Sonarr connection and UI settings
- Configurable via config files, environment variables, or CLI arguments
## Installation
@@ -39,6 +43,12 @@ Example configuration:
[sonarr]
url = "http://localhost:8989"
api_key = "your-api-key-here"
[ui]
# Keybind mode: "Normal" or "Vim"
keybind_mode = "Normal"
# Show help text in footer
show_help = true
```
### Environment Variables
@@ -48,6 +58,8 @@ Set these environment variables:
```bash
export YARR_SONARR_URL="http://localhost:8989"
export YARR_SONARR_API_KEY="your-api-key-here"
export YARR_UI_KEYBIND_MODE="Vim"
export YARR_UI_SHOW_HELP="true"
```
### Command Line Arguments
@@ -134,11 +146,49 @@ yarr completions powershell > yarr.ps1
## TUI Controls
### Normal Mode (Default)
- `q` - Quit
- `↑/↓` or `j/k` - Navigate up/down
- `Enter` - Select/expand
- `Tab` - Switch between panels
- `Tab` - Switch between tabs
- `d` - Toggle details
- `r` - Refresh data
- `/` - Search (in Search tab)
- `s` - Save configuration changes
### Vim Mode
- `q` - Quit
- `h/j/k/l` - Navigate left/down/up/right
- `w/b` - Next/previous tab
- `gg` - Go to first item
- `G` - Go to last item
- `v` - Toggle details (visual mode)
- `u` - Refresh data (undo)
- `/` - Search mode
- `i` - Insert/input mode
- `s` - Save configuration changes
### Settings Tab
Use the Settings tab to:
- Toggle between Normal and Vim keybind modes
- Enable/disable help text display
- Edit Sonarr server URL and API key
- Save configuration changes to file
Access the Settings tab by navigating to the last tab or pressing `Tab` repeatedly.
### In-App Configuration
You can configure Sonarr connection settings directly within the application:
1. Navigate to the Settings tab
2. Select "Sonarr URL" or "API Key" and press Enter
3. Type the new value and press Enter to save
4. Press 's' to save all changes to the config file
This eliminates the need to manually edit config files for basic setup.
## Getting Started

View File

@@ -172,7 +172,7 @@ impl SonarrClient {
}
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>> {
self.get_debug(&format!(
self.get(&format!(
"/series/lookup?term={}",
urlencoding::encode(term)
))

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub sonarr: SonarrConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -13,6 +14,33 @@ pub struct SonarrConfig {
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub keybind_mode: KeybindMode,
pub show_help: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeybindMode {
Normal,
Vim,
}
impl Default for KeybindMode {
fn default() -> Self {
Self::Normal
}
}
impl Default for UiConfig {
fn default() -> Self {
Self {
keybind_mode: KeybindMode::default(),
show_help: true,
}
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
@@ -20,6 +48,7 @@ impl Default for AppConfig {
url: "http://localhost:8989".to_string(),
api_key: String::new(),
},
ui: UiConfig::default(),
}
}
}
@@ -92,6 +121,10 @@ impl AppConfig {
url: "http://localhost:8989".to_string(),
api_key: "your-api-key-here".to_string(),
},
ui: UiConfig {
keybind_mode: KeybindMode::Normal,
show_help: true,
},
};
let toml_content = toml::to_string_pretty(&sample_config)?;

View File

@@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
Some(cli::Commands::Tui) | None => {
// Default to TUI mode
tui::run_app(client).await?;
tui::run_app(client, config).await?;
}
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
// Already handled above

View File

@@ -23,6 +23,7 @@ use tokio::sync::mpsc;
use crate::api::{
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
};
use crate::config::{AppConfig, KeybindMode};
#[derive(Debug)]
pub enum AppEvent {
@@ -47,11 +48,14 @@ pub enum TabIndex {
History,
Health,
Search,
Settings,
}
impl TabIndex {
fn titles() -> Vec<&'static str> {
vec!["Series", "Calendar", "Queue", "History", "Health", "Search"]
vec![
"Series", "Calendar", "Queue", "History", "Health", "Search", "Settings",
]
}
fn from_index(index: usize) -> Self {
@@ -62,11 +66,12 @@ impl TabIndex {
3 => TabIndex::History,
4 => TabIndex::Health,
5 => TabIndex::Search,
6 => TabIndex::Settings,
_ => TabIndex::Series,
}
}
fn to_index(self) -> usize {
fn to_index(&self) -> usize {
match self {
TabIndex::Series => 0,
TabIndex::Calendar => 1,
@@ -74,6 +79,7 @@ impl TabIndex {
TabIndex::History => 3,
TabIndex::Health => 4,
TabIndex::Search => 5,
TabIndex::Settings => 6,
}
}
}
@@ -87,6 +93,7 @@ pub struct App {
pub history_table_state: TableState,
pub health_list_state: ListState,
pub search_list_state: ListState,
pub settings_list_state: ListState,
pub series: Vec<Series>,
pub episodes: Vec<Episode>,
pub queue: Vec<QueueItem>,
@@ -102,6 +109,12 @@ pub struct App {
pub search_mode: bool,
pub show_details: bool,
pub input_mode: bool,
pub config: AppConfig,
pub config_changed: bool,
pub editing_url: bool,
pub editing_api_key: bool,
pub url_input: String,
pub api_key_input: String,
}
impl Default for App {
@@ -115,6 +128,7 @@ impl Default for App {
history_table_state: TableState::default(),
health_list_state: ListState::default(),
search_list_state: ListState::default(),
settings_list_state: ListState::default(),
series: Vec::new(),
episodes: Vec::new(),
queue: Vec::new(),
@@ -130,8 +144,15 @@ impl Default for App {
search_mode: false,
show_details: false,
input_mode: false,
config: AppConfig::default(),
config_changed: false,
editing_url: false,
editing_api_key: false,
url_input: String::new(),
api_key_input: String::new(),
};
app.series_list_state.select(Some(0));
app.settings_list_state.select(Some(0));
app
}
}
@@ -215,6 +236,9 @@ impl App {
self.search_list_state.select(Some(i));
}
}
TabIndex::Settings => {
self.next_settings_item();
}
}
}
@@ -316,6 +340,9 @@ impl App {
self.search_list_state.select(Some(i));
}
}
TabIndex::Settings => {
self.previous_settings_item();
}
}
}
@@ -361,9 +388,208 @@ impl App {
pub fn set_error(&mut self, error: String) {
self.error_message = Some(error);
}
pub fn go_to_first(&mut self) {
match self.current_tab {
TabIndex::Series => {
if !self.series.is_empty() {
self.series_list_state.select(Some(0));
}
}
TabIndex::Queue => {
if !self.queue.is_empty() {
self.queue_table_state.select(Some(0));
}
}
TabIndex::History => {
if !self.history.is_empty() {
self.history_table_state.select(Some(0));
}
}
TabIndex::Health => {
if !self.health.is_empty() {
self.health_list_state.select(Some(0));
}
}
TabIndex::Search => {
if !self.search_results.is_empty() {
self.search_list_state.select(Some(0));
}
}
TabIndex::Settings => {
self.settings_list_state.select(Some(0));
}
_ => {}
}
}
pub fn go_to_last(&mut self) {
match self.current_tab {
TabIndex::Series => {
if !self.series.is_empty() {
self.series_list_state.select(Some(self.series.len() - 1));
}
}
TabIndex::Queue => {
if !self.queue.is_empty() {
self.queue_table_state.select(Some(self.queue.len() - 1));
}
}
TabIndex::History => {
if !self.history.is_empty() {
self.history_table_state
.select(Some(self.history.len() - 1));
}
}
TabIndex::Health => {
if !self.health.is_empty() {
self.health_list_state.select(Some(self.health.len() - 1));
}
}
TabIndex::Search => {
if !self.search_results.is_empty() {
self.search_list_state
.select(Some(self.search_results.len() - 1));
}
}
TabIndex::Settings => {
self.settings_list_state.select(Some(4)); // Total settings options - 1
}
_ => {}
}
}
pub fn toggle_keybind_mode(&mut self) {
self.config.ui.keybind_mode = match self.config.ui.keybind_mode {
KeybindMode::Normal => KeybindMode::Vim,
KeybindMode::Vim => KeybindMode::Normal,
};
self.config_changed = true;
}
pub fn toggle_help(&mut self) {
self.config.ui.show_help = !self.config.ui.show_help;
self.config_changed = true;
}
pub fn handle_settings_input(&mut self) {
if let Some(selected) = self.settings_list_state.selected() {
match selected {
0 => self.toggle_keybind_mode(),
1 => self.toggle_help(),
2 => self.start_editing_url(),
3 => self.start_editing_api_key(),
_ => {}
}
}
}
pub fn start_editing_url(&mut self) {
self.editing_url = true;
self.input_mode = true;
self.url_input = self.config.sonarr.url.clone();
}
pub fn start_editing_api_key(&mut self) {
self.editing_api_key = true;
self.input_mode = true;
self.api_key_input = self.config.sonarr.api_key.clone();
}
pub fn finish_editing_url(&mut self) {
let trimmed_url = self.url_input.trim();
if !trimmed_url.is_empty() {
// Basic URL validation
if trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://") {
self.config.sonarr.url = trimmed_url.to_string();
self.config_changed = true;
} else {
self.set_error("URL must start with http:// or https://".to_string());
}
}
self.editing_url = false;
self.input_mode = false;
self.url_input.clear();
}
pub fn finish_editing_api_key(&mut self) {
if !self.api_key_input.trim().is_empty() {
self.config.sonarr.api_key = self.api_key_input.trim().to_string();
self.config_changed = true;
}
self.editing_api_key = false;
self.input_mode = false;
self.api_key_input.clear();
}
pub fn cancel_editing(&mut self) {
self.editing_url = false;
self.editing_api_key = false;
self.input_mode = false;
self.url_input.clear();
self.api_key_input.clear();
}
pub fn next_settings_item(&mut self) {
let i = match self.settings_list_state.selected() {
Some(i) => {
if i >= 4 {
0
} else {
i + 1
}
}
None => 0,
};
self.settings_list_state.select(Some(i));
}
pub fn previous_settings_item(&mut self) {
let i = match self.settings_list_state.selected() {
Some(i) => {
if i == 0 {
4
} else {
i - 1
}
}
None => 0,
};
self.settings_list_state.select(Some(i));
}
pub fn save_config(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.config_changed {
return Ok(());
}
// Find a writable config path
let config_paths = crate::config::AppConfig::get_default_config_paths();
let config_path = if let Some(path) = config_paths.get(1) {
// Prefer user config directory
path.clone()
} else {
std::env::current_dir()?.join("yarr.toml")
};
// Create directory if it doesn't exist
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Save the config
let toml_content = toml::to_string_pretty(&self.config)?;
std::fs::write(&config_path, toml_content)?;
self.config_changed = false;
Ok(())
}
}
pub async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Error>> {
pub async fn run_app(
client: SonarrClient,
config: AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
@@ -373,6 +599,7 @@ pub async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Err
// Create app and run it
let mut app = App::default();
app.config = config;
let res = run_tui(&mut terminal, &mut app, client).await;
// Restore terminal
@@ -588,64 +815,208 @@ async fn handle_input(
tx: mpsc::UnboundedSender<AppEvent>,
) {
if app.input_mode {
match key.code {
KeyCode::Enter => {
if !app.search_input.is_empty() {
app.loading = true;
let search_term = app.search_input.clone();
let client_clone = client.clone();
let tx_clone = tx.clone();
tokio::spawn(async move {
match client_clone.search_series(&search_term).await {
Ok(results) => {
let _ = tx_clone.send(AppEvent::SearchResults(results));
}
Err(e) => {
let _ =
tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
}
handle_input_mode(app, key, client, tx).await;
} else {
match app.config.ui.keybind_mode {
KeybindMode::Normal => handle_normal_mode(app, key, client, tx).await,
KeybindMode::Vim => handle_vim_mode(app, key, client, tx).await,
}
}
}
async fn handle_input_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
KeyCode::Enter => {
if app.editing_url {
app.finish_editing_url();
} else if app.editing_api_key {
app.finish_editing_api_key();
} else if !app.search_input.is_empty() {
app.loading = true;
let search_term = app.search_input.clone();
let client_clone = client.clone();
let tx_clone = tx.clone();
tokio::spawn(async move {
match client_clone.search_series(&search_term).await {
Ok(results) => {
let _ = tx_clone.send(AppEvent::SearchResults(results));
}
});
}
Err(e) => {
let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
}
}
});
app.input_mode = false;
}
KeyCode::Esc => {
}
KeyCode::Esc => {
if app.editing_url || app.editing_api_key {
app.cancel_editing();
} else {
app.exit_search_mode();
}
KeyCode::Char(c) => {
}
KeyCode::Char(c) => {
if app.editing_url {
app.url_input.push(c);
} else if app.editing_api_key {
app.api_key_input.push(c);
} else {
app.search_input.push(c);
}
KeyCode::Backspace => {
}
KeyCode::Backspace => {
if app.editing_url {
app.url_input.pop();
} else if app.editing_api_key {
app.api_key_input.pop();
} else {
app.search_input.pop();
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
KeyCode::Down | KeyCode::Char('j') => app.next_item(),
KeyCode::Up | KeyCode::Char('k') => app.previous_item(),
KeyCode::Char('d') => app.toggle_details(),
KeyCode::Char('r') => {
app.loading = true;
app.clear_error();
load_all_data(client, tx);
_ => {}
}
}
async fn handle_normal_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
KeyCode::Down | KeyCode::Char('j') => {
if app.current_tab == TabIndex::Settings {
app.next_settings_item();
} else {
app.next_item();
}
KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Esc => {
app.clear_error();
if app.search_mode {
app.exit_search_mode();
}
}
_ => {}
}
KeyCode::Up | KeyCode::Char('k') => {
if app.current_tab == TabIndex::Settings {
app.previous_settings_item();
} else {
app.previous_item();
}
}
KeyCode::Char('d') => app.toggle_details(),
KeyCode::Char('r') => {
app.loading = true;
app.clear_error();
tokio::spawn(async move {
load_all_data(client, tx).await;
});
}
KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Char('s') => {
if let Err(e) = app.save_config() {
app.set_error(format!("Failed to save config: {}", e));
}
}
KeyCode::Enter => {
if app.current_tab == TabIndex::Settings {
app.handle_settings_input();
}
}
KeyCode::Esc => {
app.clear_error();
if app.search_mode {
app.exit_search_mode();
}
}
_ => {}
}
}
async fn handle_vim_mode(
app: &mut App,
key: crossterm::event::KeyEvent,
client: SonarrClient,
tx: mpsc::UnboundedSender<AppEvent>,
) {
match key.code {
// Vim-like quit
KeyCode::Char('q') => app.should_quit = true,
// Tab navigation
KeyCode::Char('w') | KeyCode::Tab => app.next_tab(),
KeyCode::Char('b') | KeyCode::BackTab => app.previous_tab(),
// Vim navigation
KeyCode::Char('j') | KeyCode::Down => {
if app.current_tab == TabIndex::Settings {
app.next_settings_item();
} else {
app.next_item();
}
}
KeyCode::Char('k') | KeyCode::Up => {
if app.current_tab == TabIndex::Settings {
app.previous_settings_item();
} else {
app.previous_item();
}
}
KeyCode::Char('h') | KeyCode::Left => app.previous_tab(),
KeyCode::Char('l') | KeyCode::Right => app.next_tab(),
// Go to first/last
KeyCode::Char('g') => {
// Handle gg for go to first (simplified - in real vim this would need state)
app.go_to_first();
}
KeyCode::Char('G') => app.go_to_last(),
// Other vim-like commands
KeyCode::Char('v') => app.toggle_details(), // visual/details mode
KeyCode::Char('u') => {
// undo/refresh
app.loading = true;
app.clear_error();
tokio::spawn(async move {
load_all_data(client, tx).await;
});
}
KeyCode::Char('/') => {
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Char('i') => {
// insert mode - enter search if on search tab
if app.current_tab == TabIndex::Search {
app.enter_search_mode();
}
}
KeyCode::Enter => {
if app.current_tab == TabIndex::Settings {
app.handle_settings_input();
}
}
KeyCode::Char('s') => {
if let Err(e) = app.save_config() {
app.set_error(format!("Failed to save config: {}", e));
}
}
KeyCode::Esc => {
app.clear_error();
if app.search_mode {
app.exit_search_mode();
}
}
_ => {}
}
}
@@ -673,6 +1044,7 @@ fn ui(f: &mut Frame, app: &App) {
TabIndex::History => render_history_tab(f, chunks[1], app),
TabIndex::Health => render_health_tab(f, chunks[1], app),
TabIndex::Search => render_search_tab(f, chunks[1], app),
TabIndex::Settings => render_settings_tab(f, chunks[1], app),
}
// Render footer
@@ -1072,11 +1444,140 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) {
}
}
fn render_settings_tab(f: &mut Frame, area: Rect, app: &App) {
if app.editing_url || app.editing_api_key {
render_settings_input(f, area, app);
} else {
render_settings_list(f, area, app);
}
}
fn render_settings_list(f: &mut Frame, area: Rect, app: &App) {
let mut settings_items = vec![
ListItem::new(format!(
"Keybind Mode: {} (Press Enter to toggle)",
match app.config.ui.keybind_mode {
KeybindMode::Normal => "Normal",
KeybindMode::Vim => "Vim",
}
)),
ListItem::new(format!(
"Show Help: {} (Press Enter to toggle)",
if app.config.ui.show_help { "Yes" } else { "No" }
)),
ListItem::new(format!(
"Sonarr URL: {} (Press Enter to edit)",
if app.config.sonarr.url.is_empty() {
"Not set"
} else {
&app.config.sonarr.url
}
)),
ListItem::new(format!(
"API Key: {} (Press Enter to edit)",
if app.config.sonarr.api_key.is_empty() {
"Not set"
} else {
"***hidden***"
}
)),
ListItem::new(if app.config_changed {
"Settings changed - Press 's' to save"
} else {
"Press 's' to save settings"
}),
];
// Add keybind help based on current mode
settings_items.push(ListItem::new(""));
settings_items.push(ListItem::new("Keybind Modes:"));
settings_items.push(ListItem::new(
" Normal: Standard navigation (arrows, j/k, tab)",
));
settings_items.push(ListItem::new(
" Vim: Vim-like navigation (hjkl, gg/G, w/b)",
));
let title = if app.config_changed {
"Settings (Unsaved Changes)"
} else {
"Settings"
};
let list = List::new(settings_items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut app.settings_list_state.clone());
}
fn render_settings_input(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Input field
Constraint::Min(0), // Instructions
])
.split(area);
let (title, current_input, field_name) = if app.editing_url {
("Edit Sonarr URL", &app.url_input, "URL")
} else if app.editing_api_key {
("Edit API Key", &app.api_key_input, "API Key")
} else {
("Settings", &String::new(), "")
};
// Input field
let input = Paragraph::new(current_input.as_str())
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("{} - {}", title, field_name)),
);
f.render_widget(input, chunks[0]);
// Instructions
let instructions = vec![
Line::from("Enter: Save and return"),
Line::from("Esc: Cancel and return"),
Line::from(""),
if app.editing_url {
Line::from("Enter the Sonarr server URL (must start with http:// or https://)")
} else {
Line::from("Enter your Sonarr API key (found in Sonarr Settings > General > Security)")
},
];
let help = Paragraph::new(instructions)
.block(Block::default().borders(Borders::ALL).title("Instructions"))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[1]);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let help_text = if app.input_mode {
"ESC: Cancel | Enter: Search | Type to enter search term"
if app.editing_url || app.editing_api_key {
"ESC: Cancel | Enter: Save | Type to enter value"
} else {
"ESC: Cancel | Enter: Search | Type to enter search term"
}
} else if !app.config.ui.show_help {
"" // Don't show help if disabled
} else {
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search (in Search tab)"
match app.config.ui.keybind_mode {
KeybindMode::Normal => {
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search"
}
KeybindMode::Vim => {
"q: Quit | w/b: Tabs | hjkl: Navigate | v: Details | u: Refresh | /: Search | gg/G: First/Last"
}
}
};
let mut spans = vec![Span::raw(help_text)];

View File

@@ -6,15 +6,36 @@
[sonarr]
# Sonarr server URL (required)
# Example: "http://localhost:8989" or "https://sonarr.example.com"
# Can also be edited from the Settings tab in the application
url = "http://localhost:8989"
# Sonarr API key (required)
# You can find this in Sonarr under Settings > General > Security > API Key
# Can also be edited from the Settings tab in the application
api_key = "your-api-key-here"
[ui]
# Keybind mode: "Normal" or "Vim"
# Normal mode uses standard navigation keys (arrows, j/k)
# Vim mode uses vim-like keybinds (hjkl, gg, G, etc.)
# Can be toggled from the Settings tab in the application
keybind_mode = "Normal"
# Show help text in the footer
# Can be toggled from the Settings tab in the application
show_help = true
# All settings can be changed in the Settings tab:
# 1. Launch yarr and navigate to the Settings tab (last tab)
# 2. Use arrow keys to select a setting
# 3. Press Enter to edit Sonarr URL/API key or toggle other options
# 4. Press 's' to save changes
# Environment variables can also be used:
# YARR_SONARR_URL="http://localhost:8989"
# YARR_SONARR_API_KEY="your-api-key-here"
# YARR_UI_KEYBIND_MODE="Vim"
# YARR_UI_SHOW_HELP="false"
#
# Command line arguments take highest priority:
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"