1use super::rpg::format_compact;
2use super::sprite::sprite_lines_for_tick;
3use super::types::{BuddyState, Rarity, Species};
4
5const PORTRAIT_H: usize = 8;
8const RIGHT_W: usize = 44;
10const CAPTION_W: usize = 66;
12
13fn elem_ansi(state: &BuddyState) -> &'static str {
15 if super::super::theme::no_color() {
16 ""
17 } else {
18 state.species.element_color()
19 }
20}
21
22fn rarity_ansi(state: &BuddyState) -> &'static str {
24 if super::super::theme::no_color() {
25 ""
26 } else {
27 state.rarity.color_code()
28 }
29}
30
31fn creature_color(state: &BuddyState) -> &'static str {
35 if super::super::theme::no_color() {
36 ""
37 } else if state.prestige > 0 {
38 super::ascension::color(state.prestige)
39 } else {
40 state.species.element_color()
41 }
42}
43
44fn ascension_ansi(tier: u32) -> &'static str {
46 if super::super::theme::no_color() {
47 ""
48 } else {
49 super::ascension::color(tier)
50 }
51}
52
53pub fn format_buddy_block(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
54 format_buddy_block_at(state, theme, None)
55}
56
57pub fn format_buddy_block_at(
61 state: &BuddyState,
62 theme: &super::super::theme::Theme,
63 tick: Option<u64>,
64) -> String {
65 let r = super::super::theme::rst();
66 let dim = super::super::theme::dim();
67
68 let sprite = sprite_lines_for_tick(state, tick);
69 let portrait = portrait_box(sprite, creature_color(state));
70 let right = build_right_column(state, theme);
71
72 let rows = portrait.len().max(right.len());
73 let mut out = Vec::with_capacity(rows + 6);
74 out.push(String::new());
75
76 for i in 0..rows {
77 let pl = portrait.get(i).map_or("", String::as_str);
78 let pl = super::super::theme::pad_right(pl, super::mascot_art::width() + 2);
79 let rl = right.get(i).map_or("", String::as_str);
80 out.push(format!(" {pl} {rl}"));
81 }
82
83 out.push(String::new());
84 let mc = mood_color(theme, &state.mood);
85 let mood_line = if state.bugs_prevented > 0 {
86 format!(
87 "{mc}{} {}{r}{dim} ยท {} bugs caught{r}",
88 state.mood.icon(),
89 state.mood.label(),
90 state.bugs_prevented,
91 )
92 } else {
93 format!("{mc}{} {}{r}", state.mood.icon(), state.mood.label())
94 };
95 out.push(format!(
96 " {}",
97 super::super::theme::truncate_visual(&mood_line, CAPTION_W)
98 ));
99 let speech = format!(
101 "{dim}\u{2570}\u{2500}{r} {dim}\u{201c}{}\u{201d}{r}",
102 state.speech
103 );
104 out.push(format!(
105 " {}",
106 super::super::theme::truncate_visual(&speech, CAPTION_W)
107 ));
108
109 append_badges(&mut out, state, theme);
110
111 out.push(String::new());
112 out.join("\n")
113}
114
115fn portrait_box(sprite: &[String], element_color: &str) -> Vec<String> {
118 let r = super::super::theme::rst();
119 let w = super::mascot_art::width();
120 let top = format!("{element_color}\u{256d}{}\u{256e}{r}", "\u{2500}".repeat(w));
121 let bottom = format!("{element_color}\u{2570}{}\u{256f}{r}", "\u{2500}".repeat(w));
122
123 let h = sprite.len().min(PORTRAIT_H);
124 let top_pad = (PORTRAIT_H - h) / 2;
125
126 let mut lines = Vec::with_capacity(PORTRAIT_H + 2);
127 lines.push(top);
128 for i in 0..PORTRAIT_H {
129 let idx = i as isize - top_pad as isize;
130 let body = if idx >= 0 && (idx as usize) < sprite.len() {
131 sprite[idx as usize].as_str()
132 } else {
133 ""
134 };
135 let padded = super::super::theme::pad_right(body, w);
136 lines.push(format!(
137 "{element_color}\u{2502}{element_color}{padded}{element_color}\u{2502}{r}"
138 ));
139 }
140 lines.push(bottom);
141 lines
142}
143
144fn build_right_column(state: &BuddyState, theme: &super::super::theme::Theme) -> Vec<String> {
147 let r = super::super::theme::rst();
148 let bold = super::super::theme::bold();
149
150 let rarity_color = rarity_ansi(state);
151
152 let name = format!(
153 "{}{bold}{}{r}",
154 creature_color(state),
155 super::super::theme::truncate_visual(&state.name, 26)
156 );
157 let stars = rarity_pips(&state.rarity);
158
159 let type_line = nameplate_form_line(state, theme);
160 let rarity_label = format!("{rarity_color}{}{r}", state.rarity.label());
161
162 let mut lines = Vec::with_capacity(PORTRAIT_H + 2);
163 lines.push(right_align(&name, &stars, RIGHT_W));
164 lines.push(right_align(&type_line, &rarity_label, RIGHT_W));
165 lines.push(String::new());
166 lines.push(super::super::theme::pad_right(
167 &progression_bar(state, theme, 12),
168 RIGHT_W,
169 ));
170 lines.push(String::new());
171 lines.push(super::super::theme::pad_right(
172 &metric_value(
173 "saved",
174 &format!("{} tokens", format_compact(state.tokens_saved)),
175 ),
176 RIGHT_W,
177 ));
178 lines.push(super::super::theme::pad_right(
179 &metric_pct(theme, "compression", state.compression_pct),
180 RIGHT_W,
181 ));
182 lines.push(super::super::theme::pad_right(
183 &metric_pct(theme, "cache", state.cache_hit_rate),
184 RIGHT_W,
185 ));
186 lines.push(super::super::theme::pad_right(
187 &metric_value("streak", &format!("{} days", state.streak_days)),
188 RIGHT_W,
189 ));
190 lines
191}
192
193fn nameplate_form_line(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
199 let r = super::super::theme::rst();
200 let bold = super::super::theme::bold();
201 let dim = super::super::theme::dim();
202 let m = theme.muted.fg();
203
204 let form = if state.prestige > 0 {
205 format!(
206 "{}\u{2605}{} {bold}{}{r}",
207 ascension_ansi(state.prestige),
208 state.prestige,
209 state.form,
210 )
211 } else {
212 format!("{}{bold}{}{r}", theme.accent.fg(), state.form)
213 };
214
215 if matches!(state.species, Species::Egg) {
216 form
217 } else {
218 format!(
219 "{}{}{r} {m}{}{r} {dim}\u{00b7}{r} {form}",
220 elem_ansi(state),
221 state.species.element_glyph(),
222 state.species.element_name(),
223 )
224 }
225}
226
227fn metric_value(label: &str, value: &str) -> String {
230 let r = super::super::theme::rst();
231 let dim = super::super::theme::dim();
232 let bold = super::super::theme::bold();
233 let label_col = super::super::theme::pad_right(&format!("{dim}{label}{r}"), 13);
234 format!("{label_col}{bold}{value}{r}")
235}
236
237fn metric_pct(theme: &super::super::theme::Theme, label: &str, pct: u8) -> String {
239 let r = super::super::theme::rst();
240 let dim = super::super::theme::dim();
241 let label_col = super::super::theme::pad_right(&format!("{dim}{label}{r}"), 13);
242 let filled = (pct as usize * 8) / 100;
243 let empty = 8 - filled;
244 let pc = theme.pct_color(f64::from(pct));
245 format!(
246 "{label_col}{pc}{}{dim}{}{r} {pc}{pct:>3}%{r}",
247 "\u{2588}".repeat(filled),
248 "\u{2591}".repeat(empty),
249 )
250}
251
252fn append_badges(out: &mut Vec<String>, state: &BuddyState, theme: &super::super::theme::Theme) {
256 if state.achievement_badges.is_empty() {
257 return;
258 }
259 let r = super::super::theme::rst();
260 let bold = super::super::theme::bold();
261 let dim = super::super::theme::dim();
262 let m = theme.muted.fg();
263 let a = theme.accent.fg();
264
265 let total = super::achievements::catalog().len();
266 let got = state.achievement_badges.len();
267
268 out.push(String::new());
269 const BAR_W: usize = 12;
270 let filled = (got * BAR_W) / total.max(1);
271 out.push(format!(
272 " {dim}achievements{r} {a}{}{dim}{}{r} {bold}{got}{r}{dim}/{total}{r}",
273 "\u{2588}".repeat(filled),
274 "\u{2591}".repeat(BAR_W.saturating_sub(filled)),
275 ));
276
277 const COLS: usize = 3;
278 const CELL: usize = 21;
279 let mut col = 0usize;
280 let mut line = String::from(" ");
281 for badge in &state.achievement_badges {
282 if col == COLS {
283 out.push(std::mem::replace(&mut line, String::from(" ")));
284 col = 0;
285 }
286 line.push_str(&badge_cell(badge, CELL, &m, r));
287 col += 1;
288 }
289 if col > 0 {
290 out.push(line);
291 }
292}
293
294fn badge_cell(badge: &str, width: usize, color: &str, r: &str) -> String {
298 let stripped = strip_vs16(badge);
299 let name = stripped.split_once(' ').map_or("", |(_, n)| n);
300 let content_w = 2 + 1 + name.chars().count();
301 let pad = width.saturating_sub(content_w);
302 format!("{color}{stripped}{r}{}", " ".repeat(pad))
303}
304
305fn mood_color(theme: &super::super::theme::Theme, mood: &super::types::Mood) -> String {
307 use super::types::Mood;
308 match mood {
309 Mood::Ecstatic => theme.success.fg(),
310 Mood::Happy => theme.secondary.fg(),
311 Mood::Content => theme.accent.fg(),
312 Mood::Worried => theme.warning.fg(),
313 Mood::Sleeping => theme.muted.fg(),
314 }
315}
316
317fn strip_vs16(s: &str) -> String {
320 s.chars().filter(|&c| c != '\u{fe0f}').collect()
321}
322
323fn right_align(left: &str, right: &str, w: usize) -> String {
325 let rv = super::super::theme::visual_len(right);
326 let left = if super::super::theme::visual_len(left) + rv + 1 > w {
327 super::super::theme::truncate_visual(left, w.saturating_sub(rv + 1))
328 } else {
329 left.to_string()
330 };
331 let lv = super::super::theme::visual_len(&left);
332 let gap = w.saturating_sub(lv + rv);
333 format!("{left}{}{right}", " ".repeat(gap))
334}
335
336fn rarity_pips(rarity: &Rarity) -> String {
337 let r = super::super::theme::rst();
338 let dim = super::super::theme::dim();
339 let color = if super::super::theme::no_color() {
340 ""
341 } else {
342 rarity.color_code()
343 };
344 let filled = match rarity {
345 Rarity::Egg => 0,
346 Rarity::Common => 1,
347 Rarity::Uncommon => 2,
348 Rarity::Rare => 3,
349 Rarity::Epic => 4,
350 Rarity::Legendary => 5,
351 };
352 format!(
353 "{color}{}{r}{dim}{}{r}",
354 "\u{25c6}".repeat(filled),
355 "\u{25c7}".repeat(5 - filled),
356 )
357}
358
359fn progression_bar(state: &BuddyState, theme: &super::super::theme::Theme, width: usize) -> String {
363 let r = super::super::theme::rst();
364 let dim = super::super::theme::dim();
365 let a = theme.accent.fg();
366
367 if let Some(next) = state.evolution.next() {
368 let progress = state.evolution.progress(state.level);
369 let bar = theme.gradient_bar(progress, width);
370 return format!(
371 "{dim}evo{r} {bar} {a}{:.0}%{r} {dim}\u{2192} {}{r}",
372 progress * 100.0,
373 next.label()
374 );
375 }
376
377 let next_tier = state.prestige + 1;
379 let progress = super::ascension::progress(state.xp);
380 let bar = theme.gradient_bar(progress, width);
381 format!(
382 "{dim}ascend{r} {bar} {}\u{2605}{}{r} {dim}{:.0}% \u{2192} {}{r}",
383 ascension_ansi(next_tier),
384 next_tier,
385 progress * 100.0,
386 super::ascension::title(next_tier),
387 )
388}
389
390pub fn format_buddy_full(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
391 let rst = super::super::theme::rst();
392 let accent = theme.accent.fg();
393 let muted = theme.muted.fg();
394 let bold = super::super::theme::bold();
395 let dim = super::super::theme::dim();
396 let rarity_color = rarity_ansi(state);
397 let body_color = creature_color(state);
398
399 let mut out = Vec::new();
400
401 let rank_label = if state.prestige > 0 {
402 format!(
403 "{}\u{2605}{} {}{rst}",
404 ascension_ansi(state.prestige),
405 state.prestige,
406 state.form,
407 )
408 } else {
409 format!("{muted}{} {}{rst}", state.evolution.icon(), state.form)
410 };
411
412 out.push(String::new());
413 out.push(format!(" {bold}{accent}Pixel Sprite{rst} {rank_label}"));
414 out.push(String::new());
415
416 for line in &state.ascii_art {
417 out.push(format!(" {body_color}{line}{rst}"));
418 }
419 out.push(String::new());
420
421 let element_phrase = if matches!(state.species, Species::Egg) {
424 String::new()
425 } else {
426 format!("{muted}the {}-type{rst} ", state.species.element_name())
427 };
428 out.push(format!(
429 " {bold}{body_color}{}{rst} {element_phrase}{rarity_color}{}{rst}",
430 state.name,
431 state.rarity.label(),
432 ));
433 out.push(format!(
434 " {muted}Mood: {} | Streak: {}d | Bugs prevented: {}{rst}",
435 state.mood.label(),
436 state.streak_days,
437 state.bugs_prevented,
438 ));
439
440 let evo_bar = progression_bar(state, theme, 16);
441 out.push(format!(" {evo_bar}"));
442 out.push(String::new());
443
444 out.push(format!(" {bold}Efficiency{rst}"));
445 out.push(format!(
446 " {}",
447 metric_value(
448 "saved",
449 &format!("{} tokens", format_compact(state.tokens_saved))
450 )
451 ));
452 out.push(format!(
453 " {}",
454 metric_pct(theme, "compression", state.compression_pct)
455 ));
456 out.push(format!(
457 " {}",
458 metric_pct(theme, "cache", state.cache_hit_rate)
459 ));
460 out.push(String::new());
461
462 if !state.achievement_badges.is_empty() {
463 let total = super::achievements::catalog().len();
464 let got = state.achievement_badges.len();
465 out.push(format!(
466 " {bold}Achievements{rst} {muted}{got}/{total}{rst}"
467 ));
468 let mut col = 0usize;
469 let mut line = String::from(" ");
470 for badge in &state.achievement_badges {
471 if col == 3 {
472 out.push(std::mem::replace(&mut line, String::from(" ")));
473 col = 0;
474 }
475 line.push_str(&badge_cell(badge, 21, &muted, rst));
476 col += 1;
477 }
478 if col > 0 {
479 out.push(line);
480 }
481 out.push(String::new());
482 }
483
484 out.push(format!(" {dim}\"{}\"{rst}", state.speech));
485 out.push(String::new());
486
487 out.join("\n")
488}
489
490pub(super) fn detect_project_root_for_buddy() -> String {
491 if let Some(session) = super::super::session::SessionState::load_latest() {
492 if let Some(root) = session.project_root.as_deref()
493 && !root.trim().is_empty()
494 {
495 return root.to_string();
496 }
497 if let Some(cwd) = session.shell_cwd.as_deref()
498 && !cwd.trim().is_empty()
499 {
500 return super::super::protocol::detect_project_root_or_cwd(cwd);
501 }
502 if let Some(last) = session.files_touched.last()
503 && !last.path.trim().is_empty()
504 && let Some(parent) = std::path::Path::new(&last.path).parent()
505 {
506 let p = parent.to_string_lossy().to_string();
507 return super::super::protocol::detect_project_root_or_cwd(&p);
508 }
509 }
510 std::env::current_dir()
511 .map(|p| super::super::protocol::detect_project_root_or_cwd(&p.to_string_lossy()))
512 .unwrap_or_default()
513}