feat(cli): enhance install and removal UX with progress visibility and theming

- Introduced visible progress stages during installation, including source resolution and artifact selection.
- Improved separation between live transcript output and final summaries, ensuring clarity.
- Removed redundant recap text from installation summaries.
- Centralized terminal styling using a configurable theme system, allowing for warm defaults and user overrides.
- Added support for hex colors and named colors in the configuration.
- Updated tests to verify new behaviors and configurations.
This commit is contained in:
stoorps 2026-03-20 19:44:04 +00:00
parent c63b2917da
commit 9d8ec1e4fd
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
17 changed files with 1277 additions and 74 deletions

View file

@ -37,7 +37,11 @@ fn list_command_reads_registered_apps_from_registry_file() {
.env("AIM_REGISTRY_PATH", &registry_path)
.assert()
.success()
.stdout(contains("Bat (bat)"));
.stdout(contains("Name"))
.stdout(contains("Version"))
.stdout(contains("Source"))
.stdout(contains("Bat"))
.stdout(contains("Bat (bat)").not());
}
#[test]
@ -56,8 +60,9 @@ fn remove_command_removes_registered_app_from_registry_file() {
.env("AIM_REGISTRY_PATH", &registry_path)
.assert()
.success()
.stdout(contains("Removal Summary"))
.stdout(contains("Removed app: Bat"));
.stdout(contains("Removed Bat"))
.stdout(contains("Removal Summary").not())
.stdout(contains("Removed app:").not());
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(!contents.contains("stable_id = \"bat\""));
@ -90,8 +95,14 @@ fn remove_command_uninstalls_managed_files() {
.env("AIM_REGISTRY_PATH", &registry_path)
.assert()
.success()
.stdout(contains("Removal Summary"))
.stdout(contains("Removed app: bat"));
.stdout(contains("\nRemoved bat"))
.stdout(contains("Removed bat"))
.stdout(contains("Removal Summary").not())
.stdout(contains("Removed app:").not())
.stdout(contains("Removed files"))
.stdout(contains("sharkdp-bat.AppImage"))
.stdout(contains("aim-sharkdp-bat.desktop"))
.stdout(contains("sharkdp-bat.png"));
assert!(!payload_path.exists());
assert!(!desktop_path.exists());
@ -109,8 +120,16 @@ fn query_command_registers_unambiguous_app_in_registry_file() {
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installation Summary"))
.stdout(contains("Application: bat (sharkdp-bat)"));
.stdout(contains("\nInstalled bat (user)"))
.stdout(contains("Installed bat (user)"))
.stdout(contains("Installation Summary").not())
.stdout(contains("Source: github sharkdp/bat"))
.stdout(contains("Artifact:"))
.stdout(contains("Selected artifact").not())
.stdout(contains("metadata-guided").not())
.stdout(contains("Installed files"))
.stdout(contains("sharkdp-bat.AppImage"))
.stdout(contains("Completed steps").not());
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
@ -147,9 +166,16 @@ fn old_release_query_can_track_latest_and_register_app() {
.env("AIM_TRACKING_PREFERENCE", "latest")
.assert()
.success()
.stdout(contains("Installation Summary"))
.stdout(contains("Application: t3code (pingdotgg-t3code)"))
.stdout(contains("Install scope: user"));
.stdout(contains("\nInstalled t3code (user)"))
.stdout(contains("Installed t3code (user)"))
.stdout(contains("Installation Summary").not())
.stdout(contains("Source: github pingdotgg/t3code"))
.stdout(contains("Artifact: T3-Code-0.0.12-x86_64.AppImage"))
.stdout(contains("Selected artifact").not())
.stdout(contains("metadata-guided").not())
.stdout(contains("Installed files"))
.stdout(contains("pingdotgg-t3code.AppImage"))
.stdout(contains("Completed steps").not());
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
@ -167,8 +193,11 @@ fn cli_add_installs_and_renders_resolved_mode() {
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installation Summary"))
.stdout(contains("Selected artifact"));
.stdout(contains("\nInstalled bat (user)"))
.stdout(contains("Installed bat (user)"))
.stdout(contains("Artifact:"))
.stdout(contains("Installed files"))
.stdout(contains("Completed steps").not());
}
#[test]
@ -183,8 +212,16 @@ fn cli_add_emits_live_progress_to_stderr() {
.assert()
.success()
.stderr(contains("Installing sharkdp/bat"))
.stderr(contains("Resolving source"))
.stderr(contains("Discovering release"))
.stderr(contains("Selecting artifact"))
.stderr(contains("Downloading artifact"))
.stderr(contains("Saving registry"));
.stderr(contains("Downloaded"))
.stderr(contains("Payload Staged"))
.stderr(contains("Desktop Entry Written"))
.stderr(contains("Icon Extracted"))
.stderr(contains("Desktop Integration Refreshed"))
.stderr(contains("Registry Saved"));
}
#[test]
@ -258,8 +295,7 @@ fn system_request_on_immutable_host_falls_back_to_user_install() {
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installation Summary"))
.stdout(contains("Install scope: user"))
.stdout(contains("Installed bat (user)"))
.stdout(contains("downgraded to user scope"));
}

View file

@ -1,8 +1,15 @@
use aim_cli::DispatchResult;
use aim_cli::ui::prompt::render_interaction;
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
use aim_core::app::add::InstalledApp;
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::app::list::ListRow;
use aim_core::app::remove::{RemovalPlan, RemovalResult};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::ArtifactCandidate;
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
use aim_core::integration::install::InstallOutcome;
#[test]
fn update_summary_mentions_selected_count() {
@ -22,6 +29,24 @@ fn list_empty_state_uses_friendlier_copy() {
assert!(output.contains("No installed apps yet"));
}
#[test]
fn list_renders_table_with_name_version_and_source() {
let output = render_dispatch_result(&DispatchResult::List(vec![ListRow {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
version: Some("0.25.0".to_owned()),
source: "sharkdp/bat".to_owned(),
}]));
assert!(output.contains("Name"));
assert!(output.contains("Version"));
assert!(output.contains("Source"));
assert!(output.contains("Bat"));
assert!(output.contains("0.25.0"));
assert!(output.contains("sharkdp/bat"));
assert!(!output.contains("Bat (bat)"));
}
#[test]
fn review_flow_uses_clearer_summary_labels() {
let output = render_dispatch_result(&DispatchResult::UpdatePlan(UpdatePlan {
@ -41,6 +66,30 @@ fn review_flow_uses_clearer_summary_labels() {
assert!(output.contains("apps with updates"));
}
#[test]
fn removal_summary_lists_removed_files() {
let output = render_dispatch_result(&DispatchResult::Removed(Box::new(RemovalResult {
removed: RemovalPlan {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
artifact_paths: vec![
"/tmp/install-home/.local/lib/aim/appimages/bat.AppImage".to_owned(),
"/tmp/install-home/.local/share/applications/aim-bat.desktop".to_owned(),
],
},
removed_paths: vec![
"/tmp/install-home/.local/lib/aim/appimages/bat.AppImage".to_owned(),
"/tmp/install-home/.local/share/applications/aim-bat.desktop".to_owned(),
],
remaining_apps: Vec::new(),
warnings: Vec::new(),
})));
assert!(output.contains("Removed files"));
assert!(output.contains("bat.AppImage"));
assert!(output.contains("aim-bat.desktop"));
}
#[test]
fn tracking_prompt_mentions_requested_and_latest_versions() {
let output = render_interaction(&InteractionRequest {
@ -68,3 +117,63 @@ fn tracking_prompt_uses_explicit_question_copy() {
assert!(output.contains("Choose update tracking"));
}
#[test]
fn install_summary_omits_completed_steps_recap() {
let output = render_dispatch_result(&DispatchResult::Added(Box::new(InstalledApp {
record: AppRecord {
stable_id: "bat".to_owned(),
display_name: "bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: None,
installed_version: Some("0.25.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(
"/tmp/install-home/.local/lib/aim/appimages/sharkdp-bat.AppImage".to_owned(),
),
desktop_entry_path: Some(
"/tmp/install-home/.local/share/applications/aim-sharkdp-bat.desktop"
.to_owned(),
),
icon_path: None,
}),
},
selected_artifact: ArtifactCandidate {
url: "https://github.com/sharkdp/bat/releases/download/v0.25.0/bat-x86_64.AppImage"
.to_owned(),
version: "0.25.0".to_owned(),
arch: Some("x86_64".to_owned()),
selection_reason: "heuristic-match".to_owned(),
},
artifact_size_bytes: 173_015_040,
source: SourceRef {
kind: SourceKind::GitHub,
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: "sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
install_scope: InstallScope::User,
integration_mode: aim_core::integration::policy::IntegrationMode::Full,
install_outcome: InstallOutcome {
final_payload_path: "/tmp/install-home/.local/lib/aim/appimages/sharkdp-bat.AppImage"
.into(),
desktop_entry_path: Some(
"/tmp/install-home/.local/share/applications/aim-sharkdp-bat.desktop".into(),
),
icon_path: None,
warnings: Vec::new(),
},
warnings: Vec::new(),
})));
assert!(output.contains("Installed bat (user)"));
assert!(output.contains("Installed files"));
assert!(!output.contains("Completed steps"));
}