1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
//! Sleek TUI for `nono pull`. Streams per-file download progress as it
//! happens, then renders the install summary. Same output for the
//! explicit `nono pull <ref>` command and the auto-pull path triggered
//! by `--profile always-further/claude`.
//!
//! Design rules (do not relax without thinking):
//! - No spinners, no in-place line rewrites — output stays readable in
//! scrollback and under non-TTY (CI logs, redirected stderr).
//! - Two-space indent for everything; no boxes/borders so narrow
//! terminals don't wrap awkwardly.
//! - Color is decoration, not information: every line still parses
//! when ANSI is stripped (NO_COLOR, dumb terminals).
use crate::package::{PackageRef, PullResponse};
use colored::Colorize;
use std::io::{self, Write};
/// Per-file download progress sink. The pull pipeline calls
/// `started` before each download and `finished` once the digest is
/// verified. All methods are best-effort and never fail the pull —
/// IO errors writing to stderr are swallowed.
pub struct ProgressPrinter {
name_width: usize,
size_width: usize,
}
impl ProgressPrinter {
/// Build a printer sized to the longest filename and the widest
/// formatted size in the pull response. This lets every row align
/// without per-line padding hacks.
#[must_use]
pub fn new(pull: &PullResponse) -> Self {
let name_width = pull
.artifacts
.iter()
.map(|a| a.filename.len())
.max()
.unwrap_or(0);
let size_width = pull
.artifacts
.iter()
.map(|a| format_size(a.size_bytes).len())
.max()
.unwrap_or(0);
Self {
name_width,
size_width,
}
}
/// Print the pulling-… header. Emit once before any downloads.
pub fn header(&self, package_ref: &PackageRef) {
let mut err = io::stderr().lock();
let _ = writeln!(err);
let _ = writeln!(err, " {} pulling {}", "⬇".cyan(), package_ref.key().bold());
let _ = writeln!(err);
}
/// Mark a file as completed. Called after digest verification.
/// `bytes` is the on-disk size of the verified file.
pub fn finished(&self, filename: &str, bytes: u64) {
let mut err = io::stderr().lock();
let size = format_size(bytes as i64);
let _ = writeln!(
err,
" {name:<name_w$} {size:>size_w$} {tick}",
name = filename.dimmed(),
name_w = self.name_width,
size = size.dimmed(),
size_w = self.size_width,
tick = "✓".green(),
);
}
}
/// Render the install summary. Called once after the install
/// completes successfully.
///
/// `install_dir` is the absolute path of the installed pack inside the
/// package store. `installed_artifacts` is the count from the install
/// summary.
pub fn render_summary(
package_ref: &PackageRef,
pull: &PullResponse,
install_dir: &std::path::Path,
installed_artifacts: usize,
copied_to_project: usize,
) {
let mut err = io::stderr().lock();
let _ = writeln!(err);
let _ = writeln!(
err,
" {} {} {}",
"✓".green().bold(),
package_ref.key().bold(),
pull.version.dimmed(),
);
let _ = writeln!(err);
let _ = writeln!(
err,
" {label} {body}",
label = "Installed at".bold(),
body = install_dir.display().to_string().dimmed(),
);
let _ = writeln!(
err,
" {}",
format!("{installed_artifacts} artifact(s)").dimmed(),
);
if copied_to_project > 0 {
let _ = writeln!(err);
let _ = writeln!(
err,
" Copied {copied_to_project} instruction file(s) into the current directory",
);
}
let _ = writeln!(err);
}
/// "1.30 KB" / "412 B" / "2.10 MB" — three significant digits. Human
/// readable; precision matched across rows by `ProgressPrinter`'s
/// `size_width` calculation.
#[must_use]
pub fn format_size(bytes: i64) -> String {
let bytes = bytes.max(0) as u64;
if bytes < 1024 {
return format!("{bytes} B");
}
let kib = bytes as f64 / 1024.0;
if kib < 1024.0 {
return format!("{kib:.2} KB");
}
let mib = kib / 1024.0;
if mib < 1024.0 {
return format!("{mib:.2} MB");
}
let gib = mib / 1024.0;
format!("{gib:.2} GB")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_size_thresholds() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1023), "1023 B");
assert_eq!(format_size(1024), "1.00 KB");
assert_eq!(format_size(1500), "1.46 KB");
assert_eq!(format_size(1024 * 1024), "1.00 MB");
assert_eq!(format_size(1024 * 1024 * 1024), "1.00 GB");
}
}