Lidarr support #1

Merged
Dark-Alex-17 merged 61 commits from lidarr into main 2026-01-21 21:30:47 +00:00
16 changed files with 442 additions and 50 deletions
Showing only changes of commit e61537942b - Show all commits
+1
View File
@@ -35,6 +35,7 @@ mod tests {
theme: None,
radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]),
sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]),
lidarr: None,
};
let expected_tab_routes = vec![
TabRoute {
+12
View File
@@ -285,6 +285,12 @@ impl App<'_> {
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
@@ -309,6 +315,12 @@ impl App<'_> {
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
+37
View File
@@ -0,0 +1,37 @@
#[cfg(test)]
mod tests {
use crate::cli::{
lidarr::{list_command_handler::LidarrListCommand, LidarrCommand},
Command,
};
use crate::Cli;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_command_from() {
let command = LidarrCommand::List(LidarrListCommand::Artists);
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(command));
}
mod cli {
use super::*;
#[test]
fn test_list_artists_has_no_arg_requirements() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_lidarr_list_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list"]);
assert_err!(&result);
}
}
}
+4
View File
@@ -12,6 +12,10 @@ use crate::{
use super::LidarrCommand;
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrListCommand {
#[command(about = "List all artists in your Lidarr library")]
@@ -0,0 +1,70 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_list_command_from() {
let command = LidarrListCommand::Artists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::List(command)));
}
mod cli {
use super::*;
#[test]
fn test_list_artists_has_no_arg_requirements() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::LidarrSerdeable;
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_list_artists_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ListArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result =
LidarrListCommandHandler::with(&app_arc, LidarrListCommand::Artists, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+4
View File
@@ -14,6 +14,10 @@ use super::{CliCommandHandler, Command};
mod list_command_handler;
#[cfg(test)]
#[path = "lidarr_command_tests.rs"]
mod lidarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrCommand {
#[command(
+2 -31
View File
@@ -4,13 +4,12 @@ mod property_tests {
use crate::app::App;
use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*;
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::models::radarr_models::Movie;
use crate::models::{Scrollable, Paginated};
use crate::models::{Paginated, Scrollable};
proptest! {
/// Property test: Table never panics on index selection
#[test]
fn test_table_index_selection_safety(
list_size in list_size(),
@@ -25,19 +24,15 @@ mod property_tests {
table.set_items(movies);
// Try to select an arbitrary index
if index < list_size {
table.select_index(Some(index));
let selected = table.current_selection();
prop_assert_eq!(selected.id, index as i64);
} else {
// Out of bounds selection should be safe
table.select_index(Some(index));
// Should not panic, selection stays valid
}
}
/// Property test: Table state remains consistent after scroll operations
#[test]
fn test_table_scroll_consistency(
list_size in list_size(),
@@ -53,42 +48,34 @@ mod property_tests {
table.set_items(movies);
let initial_id = table.current_selection().id;
// Scroll down multiple times
for _ in 0..scroll_amount {
table.scroll_down();
}
let after_down_id = table.current_selection().id;
// Position should increase (up to max)
prop_assert!(after_down_id >= initial_id);
prop_assert!(after_down_id < list_size as i64);
// Scroll back up
for _ in 0..scroll_amount {
table.scroll_up();
}
// Should return to initial position (or 0 if we hit the top)
prop_assert!(table.current_selection().id <= initial_id);
}
/// Property test: Empty tables handle operations gracefully
#[test]
fn test_empty_table_safety(_scroll_ops in 0usize..50) {
let table = StatefulTable::<Movie>::default();
// Empty table operations should be safe
prop_assert!(table.is_empty());
prop_assert!(table.items.is_empty());
}
/// Property test: Navigation operations maintain consistency
#[test]
fn test_navigation_consistency(pushes in 1usize..20) {
let mut app = App::test_default();
let initial_route = app.get_current_route();
// Push multiple routes
let routes = vec![
ActiveRadarrBlock::Movies,
ActiveRadarrBlock::Collections,
@@ -101,34 +88,27 @@ mod property_tests {
app.push_navigation_stack(route.into());
}
// Current route should be the last pushed
let last_pushed = routes[(pushes - 1) % routes.len()];
prop_assert_eq!(app.get_current_route(), last_pushed.into());
// Pop all routes
for _ in 0..pushes {
app.pop_navigation_stack();
}
// Should return to initial route
prop_assert_eq!(app.get_current_route(), initial_route);
}
/// Property test: String input handling is safe
#[test]
fn test_string_input_safety(input in text_input_string()) {
// String operations should never panic
let _lowercase = input.to_lowercase();
let _uppercase = input.to_uppercase();
let _trimmed = input.trim();
let _len = input.len();
let _chars: Vec<char> = input.chars().collect();
// All operations completed without panic
prop_assert!(true);
}
/// Property test: Table maintains data integrity after operations
#[test]
fn test_table_data_integrity(
list_size in 1usize..100
@@ -144,16 +124,13 @@ mod property_tests {
table.set_items(movies.clone());
let original_count = table.items.len();
// Count should remain the same after various operations
prop_assert_eq!(table.items.len(), original_count);
// All original items should still be present
for movie in &movies {
prop_assert!(table.items.iter().any(|m| m.id == movie.id));
}
}
/// Property test: Page up/down maintains bounds
#[test]
fn test_page_navigation_bounds(
list_size in list_size(),
@@ -168,7 +145,6 @@ mod property_tests {
table.set_items(movies);
// Perform page operations
for i in 0..page_ops {
if i % 2 == 0 {
table.page_down();
@@ -176,14 +152,12 @@ mod property_tests {
table.page_up();
}
// Should never exceed bounds
let current = table.current_selection();
prop_assert!(current.id >= 0);
prop_assert!(current.id < list_size as i64);
}
}
/// Property test: Table filtering reduces or maintains size
#[test]
fn test_table_filter_size_invariant(
list_size in list_size(),
@@ -200,7 +174,6 @@ mod property_tests {
table.set_items(movies.clone());
let original_size = table.items.len();
// Apply filter
if !filter_term.is_empty() {
let filtered: Vec<Movie> = movies.into_iter()
.filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase()))
@@ -208,10 +181,8 @@ mod property_tests {
table.set_items(filtered);
}
// Filtered size should be <= original
prop_assert!(table.items.len() <= original_size);
// Selection should still be valid if table not empty
if !table.items.is_empty() {
let current = table.current_selection();
prop_assert!(current.id >= 0);
+19 -13
View File
@@ -9,22 +9,23 @@ mod tests {
use rstest::rstest;
use tokio_util::sync::CancellationToken;
use crate::app::App;
use crate::app::context_clues::SERVARR_CONTEXT_CLUES;
use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding};
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS};
use crate::app::radarr::radarr_context_clues::{
LIBRARY_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES,
};
use crate::app::App;
use crate::event::Key;
use crate::handlers::{handle_clear_errors, handle_prompt_toggle};
use crate::handlers::{handle_events, populate_keymapping_table};
use crate::models::HorizontallyScrollableText;
use crate::models::Route;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_models::KeybindingItem;
use crate::models::stateful_table::StatefulTable;
use crate::models::HorizontallyScrollableText;
use crate::models::Route;
#[test]
fn test_handle_clear_errors() {
@@ -60,11 +61,16 @@ mod tests {
}
#[rstest]
#[case(0, ActiveSonarrBlock::Series, ActiveSonarrBlock::Series)]
#[case(1, ActiveRadarrBlock::Movies, ActiveRadarrBlock::Movies)]
fn test_handle_change_tabs<T>(#[case] index: usize, #[case] left_block: T, #[case] right_block: T)
where
#[case(0, ActiveLidarrBlock::Artists, ActiveSonarrBlock::Series)]
#[case(1, ActiveRadarrBlock::Movies, ActiveLidarrBlock::Artists)]
#[case(2, ActiveSonarrBlock::Series, ActiveRadarrBlock::Movies)]
fn test_handle_change_tabs<T, U>(
#[case] index: usize,
#[case] left_block: T,
#[case] right_block: U,
) where
T: Into<Route> + Copy,
U: Into<Route> + Copy,
{
let mut app = App::test_default();
app.error = "Test".into();
@@ -122,8 +128,8 @@ mod tests {
}
#[test]
fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated()
{
fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated(
) {
let mut app = App::test_default();
let keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
@@ -254,8 +260,8 @@ mod tests {
}
#[test]
fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options()
{
fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options(
) {
let mut expected_keybinding_items = MOVIE_DETAILS_CONTEXT_CLUES
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
+4
View File
@@ -6,6 +6,10 @@ use serde_json::{Number, Value};
use super::{HorizontallyScrollableText, Serdeable};
use crate::serde_enum_from;
#[cfg(test)]
#[path = "lidarr_models_tests.rs"]
mod lidarr_models_tests;
#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Artist {
+201
View File
@@ -0,0 +1,201 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde_json::json;
use crate::models::{
lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings},
Serdeable,
};
#[test]
fn test_artist_status_default() {
assert_eq!(ArtistStatus::default(), ArtistStatus::Continuing);
}
#[test]
fn test_lidarr_serdeable_from() {
let lidarr_serdeable = LidarrSerdeable::Value(json!({}));
let serdeable: Serdeable = Serdeable::from(lidarr_serdeable.clone());
assert_eq!(serdeable, Serdeable::Lidarr(lidarr_serdeable));
}
#[test]
fn test_lidarr_serdeable_from_unit() {
let lidarr_serdeable = LidarrSerdeable::from(());
assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(json!({})));
}
#[test]
fn test_lidarr_serdeable_from_value() {
let value = json!({"test": "test"});
let lidarr_serdeable: LidarrSerdeable = value.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Value(value));
}
#[test]
fn test_lidarr_serdeable_from_artists() {
let artists = vec![Artist {
id: 1,
..Artist::default()
}];
let lidarr_serdeable: LidarrSerdeable = artists.clone().into();
assert_eq!(lidarr_serdeable, LidarrSerdeable::Artists(artists));
}
#[test]
fn test_artist_deserialization() {
let artist_json = json!({
"id": 1,
"mbId": "test-mb-id",
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"overview": "Test overview",
"artistType": "Group",
"disambiguation": "UK Band",
"path": "/music/test-artist",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"genres": ["Rock", "Alternative"],
"tags": [1, 2],
"added": "2023-01-01T00:00:00Z",
"ratings": {
"votes": 100,
"value": 4.5
},
"statistics": {
"albumCount": 5,
"trackFileCount": 50,
"trackCount": 60,
"totalTrackCount": 70,
"sizeOnDisk": 1000000000,
"percentOfTracks": 83.33
}
});
let artist: Artist = serde_json::from_value(artist_json).unwrap();
assert_eq!(artist.id, 1);
assert_str_eq!(artist.mb_id, "test-mb-id");
assert_str_eq!(artist.artist_name.text, "Test Artist");
assert_str_eq!(artist.foreign_artist_id, "test-foreign-id");
assert_eq!(artist.status, ArtistStatus::Continuing);
assert_some_eq_x!(&artist.overview, "Test overview");
assert_some_eq_x!(&artist.artist_type, "Group");
assert_some_eq_x!(&artist.disambiguation, "UK Band");
assert_str_eq!(artist.path, "/music/test-artist");
assert_eq!(artist.quality_profile_id, 1);
assert_eq!(artist.metadata_profile_id, 1);
assert!(artist.monitored);
assert_eq!(artist.genres, vec!["Rock", "Alternative"]);
assert_eq!(artist.tags.len(), 2);
assert_some!(&artist.ratings);
assert_some!(&artist.statistics);
let ratings = artist.ratings.unwrap();
assert_eq!(ratings.votes, 100);
assert_eq!(ratings.value, 4.5);
let stats = artist.statistics.unwrap();
assert_eq!(stats.album_count, 5);
assert_eq!(stats.track_file_count, 50);
assert_eq!(stats.track_count, 60);
assert_eq!(stats.total_track_count, 70);
assert_eq!(stats.size_on_disk, 1000000000);
assert_eq!(stats.percent_of_tracks, 83.33);
}
#[test]
fn test_artist_status_deserialization() {
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"continuing\"").unwrap(),
ArtistStatus::Continuing
);
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"ended\"").unwrap(),
ArtistStatus::Ended
);
assert_eq!(
serde_json::from_str::<ArtistStatus>("\"deleted\"").unwrap(),
ArtistStatus::Deleted
);
}
#[test]
fn test_ratings_equality() {
let ratings1 = Ratings {
votes: 100,
value: 4.5,
};
let ratings2 = Ratings {
votes: 100,
value: 4.5,
};
let ratings3 = Ratings {
votes: 50,
value: 3.0,
};
assert_eq!(ratings1, ratings2);
assert_ne!(ratings1, ratings3);
}
#[test]
fn test_artist_statistics_equality() {
let stats1 = ArtistStatistics {
album_count: 5,
track_file_count: 50,
track_count: 60,
total_track_count: 70,
size_on_disk: 1000000000,
percent_of_tracks: 83.33,
};
let stats2 = ArtistStatistics {
album_count: 5,
track_file_count: 50,
track_count: 60,
total_track_count: 70,
size_on_disk: 1000000000,
percent_of_tracks: 83.33,
};
let stats3 = ArtistStatistics::default();
assert_eq!(stats1, stats2);
assert_ne!(stats1, stats3);
}
#[test]
fn test_artist_with_optional_fields_none() {
let artist_json = json!({
"id": 1,
"mbId": "",
"artistName": "Test Artist",
"foreignArtistId": "",
"status": "continuing",
"path": "",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": false,
"genres": [],
"tags": [],
"added": "2023-01-01T00:00:00Z"
});
let artist: Artist = serde_json::from_value(artist_json).unwrap();
assert_none!(&artist.overview);
assert_none!(&artist.artist_type);
assert_none!(&artist.disambiguation);
assert_none!(&artist.ratings);
assert_none!(&artist.statistics);
}
}
@@ -0,0 +1,72 @@
#[cfg(test)]
mod tests {
use crate::models::lidarr_models::{Artist, LidarrSerdeable};
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent};
use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest;
use serde_json::json;
#[rstest]
#[case(LidarrEvent::HealthCheck, "/health")]
#[case(LidarrEvent::ListArtists, "/artist")]
fn test_resource(#[case] event: LidarrEvent, #[case] expected_uri: &str) {
assert_str_eq!(event.resource(), expected_uri);
}
#[test]
fn test_from_lidarr_event() {
assert_eq!(
NetworkEvent::Lidarr(LidarrEvent::HealthCheck),
NetworkEvent::from(LidarrEvent::HealthCheck)
);
}
#[tokio::test]
async fn test_handle_get_lidarr_healthcheck_event() {
let (mock, app, _server) = MockServarrApi::get()
.build_for(LidarrEvent::HealthCheck)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let _ = network.handle_lidarr_event(LidarrEvent::HealthCheck).await;
mock.assert_async().await;
}
#[tokio::test]
async fn test_handle_list_artists_event() {
let artists_json = json!([{
"id": 1,
"mbId": "test-mb-id",
"artistName": "Test Artist",
"foreignArtistId": "test-foreign-id",
"status": "continuing",
"path": "/music/test-artist",
"qualityProfileId": 1,
"metadataProfileId": 1,
"monitored": true,
"genres": [],
"tags": [],
"added": "2023-01-01T00:00:00Z"
}]);
let response: Vec<Artist> = serde_json::from_value(artists_json.clone()).unwrap();
let (mock, app, _server) = MockServarrApi::get()
.returns(artists_json)
.build_for(LidarrEvent::ListArtists)
.await;
app.lock().await.server_tabs.set_index(2);
let mut network = test_network(&app);
let result = network.handle_lidarr_event(LidarrEvent::ListArtists).await;
mock.assert_async().await;
let LidarrSerdeable::Artists(artists) = result.unwrap() else {
panic!("Expected Artists");
};
assert_eq!(artists, response);
}
}
+4
View File
@@ -7,6 +7,10 @@ use crate::{
network::RequestMethod,
};
#[cfg(test)]
#[path = "lidarr_network_tests.rs"]
mod lidarr_network_tests;
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum LidarrEvent {
HealthCheck,
+9 -3
View File
@@ -810,11 +810,16 @@ pub(in crate::network) mod test_utils {
network_event: E,
) -> (Mock, Arc<Mutex<App<'static>>>, ServerGuard)
where
E: Into<NetworkEvent> + NetworkResource,
E: Into<NetworkEvent> + NetworkResource + Clone,
{
let resource = network_event.resource();
let network_event_clone: NetworkEvent = network_event.clone().into();
let api_version = match &network_event_clone {
NetworkEvent::Lidarr(_) => "v1",
_ => "v3",
};
let mut server = Server::new_async().await;
let mut uri = format!("/api/v3{resource}");
let mut uri = format!("/api/{api_version}{resource}");
if let Some(path) = &self.path {
uri = format!("{uri}{path}");
@@ -853,9 +858,10 @@ pub(in crate::network) mod test_utils {
..ServarrConfig::default()
};
match network_event.into() {
match network_event_clone {
NetworkEvent::Radarr(_) => app.server_tabs.tabs[0].config = Some(servarr_config),
NetworkEvent::Sonarr(_) => app.server_tabs.tabs[1].config = Some(servarr_config),
NetworkEvent::Lidarr(_) => app.server_tabs.tabs[2].config = Some(servarr_config),
}
let app_arc = Arc::new(Mutex::new(app));
@@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs
expression: output
---
╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Radarr │ Sonarr <?> to open help│
│ Radarr │ Sonarr │ Lidarr <?> to open help│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮
│Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │
@@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs
expression: output
---
╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Radarr │ Sonarr <?> to open help│
│ Radarr │ Sonarr │ Lidarr <?> to open help│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮
│Radarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │
@@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs
expression: output
---
╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Radarr │ Sonarr <?> to open help│
│ Radarr │ Sonarr │ Lidarr <?> to open help│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭ Error | <esc> to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Some error │