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

@ -27,19 +27,37 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
build_add_plan_with(query, transport.as_ref())
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport.as_ref(), &mut reporter)
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport, &mut reporter)
}
pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
});
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
@ -60,6 +78,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
@ -89,6 +111,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
)
}
_ => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
@ -249,17 +275,6 @@ pub fn install_app_with_reporter(
)),
};
if desktop_owned.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
@ -280,6 +295,20 @@ pub fn install_app_with_reporter(
})
.map_err(InstallAppError::Install)?;
if install_outcome.desktop_entry_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
}
if install_outcome.icon_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
message: "refreshing desktop integration".to_owned(),
@ -308,6 +337,7 @@ pub fn install_app_with_reporter(
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
artifact_size_bytes: artifact_bytes.len() as u64,
source: plan.resolution.source.clone(),
install_scope: policy.scope,
integration_mode: policy.integration_mode,
@ -326,6 +356,7 @@ pub fn install_app_with_reporter(
pub struct InstalledApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub artifact_size_bytes: u64,
pub source: crate::domain::source::SourceRef,
pub install_scope: InstallScope,
pub integration_mode: IntegrationMode,

View file

@ -4,6 +4,8 @@ use crate::domain::app::AppRecord;
pub struct ListRow {
pub stable_id: String,
pub display_name: String,
pub version: Option<String>,
pub source: String,
}
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
@ -11,6 +13,13 @@ pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
.map(|app| ListRow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
version: app.installed_version.clone(),
source: app
.source
.as_ref()
.map(|source| source.locator.clone())
.or_else(|| app.source_input.clone())
.unwrap_or_else(|| "-".to_owned()),
})
.collect()
}

View file

@ -93,7 +93,7 @@ pub fn remove_registered_app_with_reporter(
stage: OperationStage::StagePayload,
message: "removing managed artifacts".to_owned(),
});
let warnings = delete_artifacts(&plan)?;
let deletion = delete_artifacts(&plan)?;
let remaining_apps = apps
.iter()
.filter(|candidate| candidate.stable_id != app.stable_id)
@ -102,8 +102,9 @@ pub fn remove_registered_app_with_reporter(
let result = RemovalResult {
removed: plan,
removed_paths: deletion.removed_paths,
remaining_apps,
warnings,
warnings: deletion.warnings,
};
reporter.report(&OperationEvent::StageChanged {
@ -120,6 +121,7 @@ pub fn remove_registered_app_with_reporter(
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalResult {
pub removed: RemovalPlan,
pub removed_paths: Vec<String>,
pub remaining_apps: Vec<AppRecord>,
pub warnings: Vec<String>,
}
@ -161,13 +163,19 @@ fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf>
]
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredAppError> {
struct DeletionOutcome {
removed_paths: Vec<String>,
warnings: Vec<String>,
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<DeletionOutcome, RemoveRegisteredAppError> {
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
let mut removed_paths = Vec::new();
for artifact_path in &plan.artifact_paths {
match fs::remove_file(artifact_path) {
Ok(()) => {}
Ok(()) => removed_paths.push(artifact_path.clone()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
}
@ -182,5 +190,8 @@ fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredA
));
}
Ok(warnings)
Ok(DeletionOutcome {
removed_paths,
warnings,
})
}