@opencode-cloud/core 3.1.1 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,20 +40,6 @@ fn strip_ansi_codes(s: &str) -> String {
40
40
  result
41
41
  }
42
42
 
43
- /// Format duration as MM:SS, or HH:MM:SS if over an hour
44
- fn format_elapsed(duration: Duration) -> String {
45
- let total_secs = duration.as_secs();
46
- let hours = total_secs / 3600;
47
- let minutes = (total_secs % 3600) / 60;
48
- let seconds = total_secs % 60;
49
-
50
- if hours > 0 {
51
- format!("{hours:02}:{minutes:02}:{seconds:02}")
52
- } else {
53
- format!("{minutes:02}:{seconds:02}")
54
- }
55
- }
56
-
57
43
  /// Progress reporter for Docker operations
58
44
  ///
59
45
  /// Manages multiple progress bars for concurrent operations like
@@ -61,11 +47,12 @@ fn format_elapsed(duration: Duration) -> String {
61
47
  pub struct ProgressReporter {
62
48
  multi: MultiProgress,
63
49
  bars: HashMap<String, ProgressBar>,
64
- last_update: HashMap<String, Instant>,
65
- last_message: HashMap<String, String>,
66
- start_time: Instant,
50
+ last_update_by_id: HashMap<String, Instant>,
51
+ last_message_by_id: HashMap<String, String>,
67
52
  /// Optional context prefix shown before step messages (e.g., "Building Docker image")
68
53
  context: Option<String>,
54
+ /// When true, print build output lines directly instead of spinners
55
+ plain_output: bool,
69
56
  }
70
57
 
71
58
  impl Default for ProgressReporter {
@@ -80,10 +67,10 @@ impl ProgressReporter {
80
67
  Self {
81
68
  multi: MultiProgress::new(),
82
69
  bars: HashMap::new(),
83
- last_update: HashMap::new(),
84
- last_message: HashMap::new(),
85
- start_time: Instant::now(),
70
+ last_update_by_id: HashMap::new(),
71
+ last_message_by_id: HashMap::new(),
86
72
  context: None,
73
+ plain_output: false,
87
74
  }
88
75
  }
89
76
 
@@ -94,17 +81,32 @@ impl ProgressReporter {
94
81
  Self {
95
82
  multi: MultiProgress::new(),
96
83
  bars: HashMap::new(),
97
- last_update: HashMap::new(),
98
- last_message: HashMap::new(),
99
- start_time: Instant::now(),
84
+ last_update_by_id: HashMap::new(),
85
+ last_message_by_id: HashMap::new(),
100
86
  context: Some(context.to_string()),
87
+ plain_output: false,
101
88
  }
102
89
  }
103
90
 
91
+ /// Create a progress reporter that prints build output directly
92
+ pub fn with_context_plain(context: &str) -> Self {
93
+ Self {
94
+ multi: MultiProgress::new(),
95
+ bars: HashMap::new(),
96
+ last_update_by_id: HashMap::new(),
97
+ last_message_by_id: HashMap::new(),
98
+ context: Some(context.to_string()),
99
+ plain_output: true,
100
+ }
101
+ }
102
+
103
+ /// Check if plain output mode is enabled
104
+ pub fn is_plain_output(&self) -> bool {
105
+ self.plain_output
106
+ }
107
+
104
108
  /// Format a message with context prefix if set
105
109
  fn format_message(&self, message: &str) -> String {
106
- let elapsed = format_elapsed(self.start_time.elapsed());
107
-
108
110
  // Strip ANSI escape codes that Docker may include in its output
109
111
  let stripped = strip_ansi_codes(message);
110
112
 
@@ -113,20 +115,25 @@ impl ProgressReporter {
113
115
  // - Trim leading/trailing whitespace
114
116
  let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
115
117
 
116
- // Format: "[elapsed] Context · message" or "[elapsed] message"
117
- // Timer at the beginning for easy scanning
118
+ // Format: "Context · message" or "message"
118
119
  match &self.context {
119
- Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
120
- None => format!("[{elapsed}] {clean_msg}"),
120
+ Some(ctx) => format!("{ctx} · {clean_msg}"),
121
+ None => clean_msg,
121
122
  }
122
123
  }
123
124
 
124
125
  /// Create a spinner for indeterminate progress (e.g., build steps)
125
126
  pub fn add_spinner(&mut self, id: &str, message: &str) -> &ProgressBar {
127
+ if self.plain_output {
128
+ let spinner = ProgressBar::hidden();
129
+ self.bars.insert(id.to_string(), spinner);
130
+ return self.bars.get(id).expect("just inserted");
131
+ }
132
+
126
133
  let spinner = self.multi.add(ProgressBar::new_spinner());
127
134
  spinner.set_style(
128
135
  ProgressStyle::default_spinner()
129
- .template("{spinner:.green} {msg}")
136
+ .template("{spinner:.green} [{elapsed}] {msg}")
130
137
  .expect("valid template")
131
138
  .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
132
139
  );
@@ -140,6 +147,12 @@ impl ProgressReporter {
140
147
  ///
141
148
  /// `total` is in bytes
142
149
  pub fn add_bar(&mut self, id: &str, total: u64) -> &ProgressBar {
150
+ if self.plain_output {
151
+ let bar = ProgressBar::hidden();
152
+ self.bars.insert(id.to_string(), bar);
153
+ return self.bars.get(id).expect("just inserted");
154
+ }
155
+
143
156
  let bar = self.multi.add(ProgressBar::new(total));
144
157
  bar.set_style(
145
158
  ProgressStyle::default_bar()
@@ -158,6 +171,10 @@ impl ProgressReporter {
158
171
  ///
159
172
  /// `current` and `total` are in bytes, `status` is the Docker status message
160
173
  pub fn update_layer(&mut self, layer_id: &str, current: u64, total: u64, status: &str) {
174
+ if self.plain_output {
175
+ return;
176
+ }
177
+
161
178
  if let Some(bar) = self.bars.get(layer_id) {
162
179
  // Update total if it changed (Docker sometimes updates this)
163
180
  if bar.length() != Some(total) && total > 0 {
@@ -178,19 +195,29 @@ impl ProgressReporter {
178
195
  /// Updates are throttled to prevent flickering from rapid message changes.
179
196
  /// "Step X/Y" messages always update immediately as they indicate significant progress.
180
197
  pub fn update_spinner(&mut self, id: &str, message: &str) {
198
+ if self.plain_output {
199
+ let clean = strip_ansi_codes(message);
200
+ let formatted = match &self.context {
201
+ Some(ctx) => format!("{ctx} · {clean}"),
202
+ None => clean,
203
+ };
204
+ eprintln!("{formatted}");
205
+ return;
206
+ }
207
+
181
208
  let now = Instant::now();
182
209
  let is_step_message = message.starts_with("Step ");
183
210
 
184
211
  // Check if we should throttle this update
185
212
  if !is_step_message {
186
- if let Some(last) = self.last_update.get(id) {
213
+ if let Some(last) = self.last_update_by_id.get(id) {
187
214
  if now.duration_since(*last) < SPINNER_UPDATE_THROTTLE {
188
215
  return; // Throttle: too soon since last update
189
216
  }
190
217
  }
191
218
 
192
219
  // Skip if message is identical to last one
193
- if let Some(last_msg) = self.last_message.get(id) {
220
+ if let Some(last_msg) = self.last_message_by_id.get(id) {
194
221
  if last_msg == message {
195
222
  return;
196
223
  }
@@ -208,8 +235,8 @@ impl ProgressReporter {
208
235
  }
209
236
 
210
237
  // Track update time and message
211
- self.last_update.insert(id.to_string(), now);
212
- self.last_message
238
+ self.last_update_by_id.insert(id.to_string(), now);
239
+ self.last_message_by_id
213
240
  .insert(id.to_string(), message.to_string());
214
241
  }
215
242
 
@@ -300,30 +327,6 @@ mod tests {
300
327
  reporter.abandon_all("Failed");
301
328
  }
302
329
 
303
- #[test]
304
- fn format_elapsed_shows_seconds_only() {
305
- let duration = Duration::from_secs(45);
306
- assert_eq!(format_elapsed(duration), "00:45");
307
- }
308
-
309
- #[test]
310
- fn format_elapsed_shows_minutes_and_seconds() {
311
- let duration = Duration::from_secs(90); // 1m 30s
312
- assert_eq!(format_elapsed(duration), "01:30");
313
- }
314
-
315
- #[test]
316
- fn format_elapsed_shows_hours_when_needed() {
317
- let duration = Duration::from_secs(3661); // 1h 1m 1s
318
- assert_eq!(format_elapsed(duration), "01:01:01");
319
- }
320
-
321
- #[test]
322
- fn format_elapsed_zero() {
323
- let duration = Duration::from_secs(0);
324
- assert_eq!(format_elapsed(duration), "00:00");
325
- }
326
-
327
330
  #[test]
328
331
  fn with_context_sets_context() {
329
332
  let reporter = ProgressReporter::with_context("Building Docker image");
@@ -335,27 +338,24 @@ mod tests {
335
338
  fn format_message_includes_context_for_steps() {
336
339
  let reporter = ProgressReporter::with_context("Building Docker image");
337
340
  let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
338
- // Format: [elapsed] Context · message
339
- assert!(msg.contains("Building Docker image · Step 1/10"));
340
- assert!(msg.starts_with("[00:00]"));
341
+ // Format: Context · message
342
+ assert!(msg.starts_with("Building Docker image · Step 1/10"));
341
343
  }
342
344
 
343
345
  #[test]
344
346
  fn format_message_includes_context_for_all_messages() {
345
347
  let reporter = ProgressReporter::with_context("Building Docker image");
346
348
  let msg = reporter.format_message("Compiling foo v1.0");
347
- // Format: [elapsed] Context · message
348
- assert!(msg.contains("Building Docker image · Compiling foo"));
349
- assert!(msg.starts_with("[00:00]"));
349
+ // Format: Context · message
350
+ assert!(msg.starts_with("Building Docker image · Compiling foo"));
350
351
  }
351
352
 
352
353
  #[test]
353
354
  fn format_message_without_context() {
354
355
  let reporter = ProgressReporter::new();
355
356
  let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
356
- // Format: [elapsed] message (no context, no dot)
357
- assert!(msg.contains("Step 1/10"));
358
- assert!(msg.starts_with("[00:00]"));
357
+ // Format: message (no context, no dot)
358
+ assert!(msg.starts_with("Step 1/10"));
359
359
  assert!(!msg.contains("·"));
360
360
  }
361
361