1use serde::{Deserialize, Serialize};
28pub mod renderer;
29
30#[derive(Debug, Clone, PartialEq)]
39pub struct Shape {
40 pub name: &'static str,
42 pub font: Font,
44 pub cell_width: u32,
45 pub cell_height: u32,
46 pub stretch: bool,
47 pub variant: Variant,
48 pub stopword_dim: bool,
49 pub columns: u32,
50 pub line_repeat: u32,
51 pub frame_size: u32,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum Font {
57 FiveByEight,
58 EightByEight,
59 SixByTwelve,
60 EightByThirteen,
61 Silver,
62}
63
64impl Font {
65 pub fn as_str(&self) -> &'static str {
67 match self {
68 Font::FiveByEight => "5x8",
69 Font::EightByEight => "8x8",
70 Font::SixByTwelve => "6x12",
71 Font::EightByThirteen => "8x13",
72 Font::Silver => "silver",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Variant {
80 Sent,
82 Bw,
84}
85
86impl Variant {
87 pub fn as_str(&self) -> &'static str {
88 match self {
89 Variant::Sent => "sent",
90 Variant::Bw => "bw",
91 }
92 }
93}
94
95pub const SHAPES: &[Shape] = &[
98 Shape {
100 name: "8x8r-bw",
101 font: Font::EightByEight,
102 cell_width: 8,
103 cell_height: 8,
104 stretch: false,
105 variant: Variant::Bw,
106 stopword_dim: false,
107 columns: 1,
108 line_repeat: 2,
109 frame_size: 1568,
110 },
111 Shape {
112 name: "8x8r-sent",
113 font: Font::EightByEight,
114 cell_width: 8,
115 cell_height: 8,
116 stretch: false,
117 variant: Variant::Sent,
118 stopword_dim: false,
119 columns: 1,
120 line_repeat: 2,
121 frame_size: 1568,
122 },
123 Shape {
126 name: "8x8u-bw",
127 font: Font::EightByEight,
128 cell_width: 8,
129 cell_height: 8,
130 stretch: false,
131 variant: Variant::Bw,
132 stopword_dim: false,
133 columns: 1,
134 line_repeat: 1,
135 frame_size: 1568,
136 },
137 Shape {
138 name: "8x8u-sent",
139 font: Font::EightByEight,
140 cell_width: 8,
141 cell_height: 8,
142 stretch: false,
143 variant: Variant::Sent,
144 stopword_dim: false,
145 columns: 1,
146 line_repeat: 1,
147 frame_size: 1568,
148 },
149 Shape {
151 name: "6x6u-bw",
152 font: Font::EightByEight,
153 cell_width: 6,
154 cell_height: 6,
155 stretch: true,
156 variant: Variant::Bw,
157 stopword_dim: false,
158 columns: 1,
159 line_repeat: 1,
160 frame_size: 1568,
161 },
162 Shape {
163 name: "6x6u-sent",
164 font: Font::EightByEight,
165 cell_width: 6,
166 cell_height: 6,
167 stretch: true,
168 variant: Variant::Sent,
169 stopword_dim: false,
170 columns: 1,
171 line_repeat: 1,
172 frame_size: 1568,
173 },
174 Shape {
176 name: "5x8-bw",
177 font: Font::FiveByEight,
178 cell_width: 5,
179 cell_height: 8,
180 stretch: false,
181 variant: Variant::Bw,
182 stopword_dim: false,
183 columns: 1,
184 line_repeat: 1,
185 frame_size: 2576,
186 },
187 Shape {
188 name: "5x8-sent",
189 font: Font::FiveByEight,
190 cell_width: 5,
191 cell_height: 8,
192 stretch: false,
193 variant: Variant::Sent,
194 stopword_dim: false,
195 columns: 1,
196 line_repeat: 1,
197 frame_size: 2576,
198 },
199 Shape {
201 name: "6x12-dim",
202 font: Font::SixByTwelve,
203 cell_width: 6,
204 cell_height: 12,
205 stretch: false,
206 variant: Variant::Bw,
207 stopword_dim: true,
208 columns: 1,
209 line_repeat: 1,
210 frame_size: 1568,
211 },
212 Shape {
214 name: "8x13-bw",
215 font: Font::EightByThirteen,
216 cell_width: 8,
217 cell_height: 13,
218 stretch: false,
219 variant: Variant::Bw,
220 stopword_dim: false,
221 columns: 1,
222 line_repeat: 1,
223 frame_size: 1568,
224 },
225 Shape {
227 name: "8on16-bw",
228 font: Font::EightByThirteen,
229 cell_width: 8,
230 cell_height: 16,
231 stretch: false,
232 variant: Variant::Bw,
233 stopword_dim: false,
234 columns: 1,
235 line_repeat: 1,
236 frame_size: 1568,
237 },
238 Shape {
239 name: "8on22-bw",
240 font: Font::EightByThirteen,
241 cell_width: 8,
242 cell_height: 22,
243 stretch: false,
244 variant: Variant::Bw,
245 stopword_dim: false,
246 columns: 1,
247 line_repeat: 1,
248 frame_size: 1568,
249 },
250 Shape {
252 name: "11on16-bw",
253 font: Font::EightByThirteen,
254 cell_width: 11,
255 cell_height: 16,
256 stretch: false,
257 variant: Variant::Bw,
258 stopword_dim: false,
259 columns: 1,
260 line_repeat: 1,
261 frame_size: 1568,
262 },
263 Shape {
265 name: "silver16-bw",
266 font: Font::Silver,
267 cell_width: 16,
268 cell_height: 16,
269 stretch: false,
270 variant: Variant::Bw,
271 stopword_dim: false,
272 columns: 1,
273 line_repeat: 1,
274 frame_size: 1568,
275 },
276 Shape {
278 name: "doc-8on16-bw",
279 font: Font::EightByThirteen,
280 cell_width: 8,
281 cell_height: 16,
282 stretch: false,
283 variant: Variant::Bw,
284 stopword_dim: false,
285 columns: 2,
286 line_repeat: 1,
287 frame_size: 1568,
288 },
289 Shape {
290 name: "doc-8on16-sent",
291 font: Font::EightByThirteen,
292 cell_width: 8,
293 cell_height: 16,
294 stretch: false,
295 variant: Variant::Sent,
296 stopword_dim: false,
297 columns: 2,
298 line_repeat: 1,
299 frame_size: 1568,
300 },
301 Shape {
302 name: "doc-8on16-sent-dim",
303 font: Font::EightByThirteen,
304 cell_width: 8,
305 cell_height: 16,
306 stretch: false,
307 variant: Variant::Sent,
308 stopword_dim: true,
309 columns: 2,
310 line_repeat: 1,
311 frame_size: 1568,
312 },
313];
314
315impl Shape {
316 pub fn name(&self) -> &'static str {
318 self.name
319 }
320
321 pub fn chars_per_frame(&self) -> u32 {
323 self.frame_size / self.cell_width.max(1)
324 }
325
326 pub fn rows_per_frame(&self) -> u32 {
328 self.frame_size / self.cell_height.max(1)
329 }
330}
331
332pub fn resolve_shape(model_id: &str) -> Shape {
334 let id = model_id.to_ascii_lowercase();
335 if id.contains("claude") || id.contains("anthropic") {
336 return lookup_owned("11on16-bw").unwrap_or_else(|| SHAPES[0].clone());
337 }
338 if id.contains("gpt-5") || id.contains("gpt-4.1") || id.contains("o3") || id.contains("o4") {
339 return lookup_owned("8on22-bw").unwrap_or_else(|| SHAPES[0].clone());
340 }
341 if id.contains("gemini") {
342 return lookup_owned("8on22-bw").unwrap_or_else(|| SHAPES[0].clone());
343 }
344 lookup_owned("11on16-bw").unwrap_or_else(|| SHAPES[0].clone())
346}
347
348fn lookup_owned(name: &str) -> Option<Shape> {
349 SHAPES.iter().find(|s| s.name == name).cloned()
350}
351
352pub fn normalize(input: &str) -> String {
356 let bytes = input.as_bytes();
357 let mut out = String::with_capacity(input.len());
358 let mut i = 0;
359 while i < bytes.len() {
360 if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
361 i += 2;
362 while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
363 i += 1;
364 }
365 i += 1;
366 continue;
367 }
368 let c = input[i..].chars().next().unwrap_or(' ');
369 match c {
370 '─' | '━' | '│' | '┃' | '┌' | '┍' | '┎' | '┏' | '┐' | '┑' | '┒' | '┓' | '└' | '┕'
371 | '┖' | '┗' | '┘' | '┙' | '┚' | '┛' | '├' | '┝' | '┞' | '┟' | '┠' | '┡' | '┢' | '┣'
372 | '┤' | '┥' | '┦' | '┧' | '┨' | '┩' | '┪' | '┫' | '┬' | '┭' | '┮' | '┯' | '┰' | '┱'
373 | '┲' | '┳' | '┴' | '┵' | '┶' | '┷' | '┸' | '┹' | '┺' | '┻' | '┼' | '┽' | '┾' | '┿' =>
374 {
375 out.push('-');
376 i += c.len_utf8();
377 }
378 '═' => {
379 out.push('=');
380 i += c.len_utf8();
381 }
382 '║' => {
383 out.push('|');
384 i += c.len_utf8();
385 }
386 '\n' => {
387 out.push('\u{2588}');
388 i += 1;
389 while i < bytes.len() && bytes[i] == b'\n' {
390 i += 1;
391 }
392 }
393 ' ' | '\t' => {
394 if !out.ends_with(' ') {
395 out.push(' ');
396 }
397 i += c.len_utf8();
398 }
399 _ => {
400 out.push(c);
401 i += c.len_utf8();
402 }
403 }
404 }
405 out
406}
407
408#[derive(Debug, Clone)]
412pub struct CompactPreparation {
413 pub text: String,
414 pub bounded_text: String,
415 pub remaining_text: String,
416}
417
418pub fn prepare(input: &str, tool_result_max_chars: usize, text_limit: usize) -> CompactPreparation {
420 let text = serialize_conversation(input, tool_result_max_chars);
421 let bounded = bounded_slice(&text, text_limit);
422 let consumed = bounded.chars().count();
423 let remaining = if consumed >= text.chars().count() {
424 String::new()
425 } else {
426 text.chars().skip(consumed).collect()
427 };
428 CompactPreparation {
429 text,
430 bounded_text: bounded,
431 remaining_text: remaining,
432 }
433}
434
435pub fn serialize_conversation(input: &str, tool_result_max_chars: usize) -> String {
437 let mut out = String::new();
438 for line in input.lines() {
439 let trimmed = line.trim();
440 if trimmed.is_empty() {
441 continue;
442 }
443 if line.starts_with("Tool:") {
444 let truncated = truncate_tool_output(trimmed, tool_result_max_chars);
445 out.push_str(&truncated);
446 out.push('\n');
447 } else {
448 out.push_str(trimmed);
449 out.push('\n');
450 }
451 }
452 out
453}
454
455fn truncate_tool_output(line: &str, max_chars: usize) -> String {
456 if line.chars().count() <= max_chars {
457 return line.to_string();
458 }
459 let head_ratio = 0.6;
460 let head = (max_chars as f64 * head_ratio) as usize;
461 let tail = max_chars.saturating_sub(head);
462 let chars: Vec<char> = line.chars().collect();
463 let head_str: String = chars.iter().take(head).collect();
464 let tail_str: String = chars
465 .iter()
466 .skip(chars.len().saturating_sub(tail))
467 .collect();
468 format!("{head_str}…[truncated]…{tail_str}")
469}
470
471fn bounded_slice(s: &str, max_chars: usize) -> String {
472 if s.chars().count() <= max_chars {
473 return s.to_string();
474 }
475 s.chars().take(max_chars).collect()
476}
477
478#[derive(Debug, Clone)]
482pub struct CompactOptions {
483 pub model_id: String,
484 pub max_frames: u32,
485 pub shape: Option<Shape>,
486}
487
488impl Default for CompactOptions {
489 fn default() -> Self {
490 Self {
491 model_id: String::new(),
492 max_frames: 80,
493 shape: None,
494 }
495 }
496}
497
498#[derive(Debug, Clone)]
500pub struct CompactResult {
501 pub summary: String,
502 pub source_text: String,
503 pub frames: Vec<FrameRef>,
504 pub shape: Shape,
505}
506
507#[derive(Debug, Clone)]
509pub struct FrameRef {
510 pub index: u32,
511 pub source_start: usize,
512 pub source_end: usize,
513 pub bytes: Vec<u8>,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct BoundedSource {
521 pub lead_in: String,
522 pub text: String,
523 pub frames: Vec<FrameRange>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
528pub struct FrameRange {
529 pub index: u32,
530 pub source_start: usize,
531 pub source_end: usize,
532}
533
534impl CompactResult {
535 pub fn into_bounded_source(&self) -> BoundedSource {
537 BoundedSource {
538 lead_in: self.summary.clone(),
539 text: self.source_text.clone(),
540 frames: self
541 .frames
542 .iter()
543 .map(|f| FrameRange {
544 index: f.index,
545 source_start: f.source_start,
546 source_end: f.source_end,
547 })
548 .collect(),
549 }
550 }
551}
552
553fn shape_to_render_options(shape: &Shape) -> crate::renderer::SnapcompactRenderOptions {
559 crate::renderer::SnapcompactRenderOptions {
560 size: shape.frame_size,
561 font: Some(shape.font.as_str().to_string()),
562 cell_width: Some(shape.cell_width),
563 cell_height: Some(shape.cell_height),
564 variant: Some(shape.variant.as_str().to_string()),
565 line_repeat: Some(shape.line_repeat),
566 stretch: Some(shape.stretch),
567 columns: Some(shape.columns),
568 }
569}
570
571pub fn compact(prep: &CompactPreparation, options: &CompactOptions) -> CompactResult {
582 let shape: Shape = options
583 .shape
584 .clone()
585 .unwrap_or_else(|| resolve_shape(&options.model_id));
586 let chars_per_frame = shape.chars_per_frame() as usize;
587 let frames = chunk_into_frames(
588 &prep.bounded_text,
589 chars_per_frame,
590 options.max_frames as usize,
591 );
592 let render_opts = shape_to_render_options(&shape);
593 let rendered: Vec<FrameRef> = frames
594 .iter()
595 .map(|range| {
596 let slice = slice_char_range(&prep.bounded_text, range.source_start, range.source_end);
597 let bytes = match crate::renderer::render_snapcompact_png(slice, render_opts.clone()) {
598 Ok(b) => b,
599 Err(e) => {
600 tracing::warn!(
601 frame = range.index,
602 shape = %shape.name,
603 error = %e,
604 "snapcompact render failed; emitting empty frame"
605 );
606 Vec::new()
607 }
608 };
609 FrameRef {
610 index: range.index,
611 source_start: range.source_start,
612 source_end: range.source_end,
613 bytes,
614 }
615 })
616 .collect();
617 CompactResult {
618 summary: render_lead_in(&prep.text),
619 source_text: prep.bounded_text.clone(),
620 frames: rendered,
621 shape,
622 }
623}
624
625fn chunk_into_frames(text: &str, chars_per_frame: usize, max_frames: usize) -> Vec<FrameRange> {
626 let chars: Vec<char> = text.chars().collect();
627 let mut out = Vec::new();
628 if chars_per_frame == 0 || chars.is_empty() || max_frames == 0 {
629 return out;
630 }
631 let mut start = 0usize;
632 let mut idx: u32 = 0;
633 while start < chars.len() && idx < max_frames as u32 {
634 let end = (start + chars_per_frame).min(chars.len());
635 out.push(FrameRange {
636 index: idx,
637 source_start: start,
638 source_end: end,
639 });
640 start = end;
641 idx += 1;
642 }
643 out
644}
645
646fn slice_char_range(s: &str, start: usize, end: usize) -> String {
647 s.chars()
648 .skip(start)
649 .take(end.saturating_sub(start))
650 .collect()
651}
652
653fn render_lead_in(text: &str) -> String {
654 let chars: Vec<char> = text.chars().collect();
655 let head: String = chars.iter().take(120).collect();
656 format!("Resume prior conversation. {head}…")
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn shape_table_includes_all_omp_variants() {
665 for name in &[
666 "8x8r-bw",
667 "8x8r-sent",
668 "8x8u-bw",
669 "8x8u-sent",
670 "6x6u-bw",
671 "6x6u-sent",
672 "5x8-bw",
673 "5x8-sent",
674 "6x12-dim",
675 "8x13-bw",
676 "8on16-bw",
677 "8on22-bw",
678 "11on16-bw",
679 "silver16-bw",
680 "doc-8on16-bw",
681 "doc-8on16-sent",
682 "doc-8on16-sent-dim",
683 ] {
684 assert!(
685 lookup_owned(name).is_some(),
686 "missing shape `{name}` from registry"
687 );
688 }
689 }
690
691 #[test]
692 fn shape_name_round_trip() {
693 for s in SHAPES {
694 assert_eq!(lookup_owned(s.name).map(|x| x.name), Some(s.name));
695 }
696 }
697
698 #[test]
699 fn resolve_shape_anthropic_picks_11on16() {
700 let s = resolve_shape("claude-3-5-sonnet-20241022");
701 assert_eq!(s.name, "11on16-bw");
702 }
703
704 #[test]
705 fn resolve_shape_openai_picks_8on22() {
706 let s = resolve_shape("gpt-5.5");
707 assert_eq!(s.name, "8on22-bw");
708 }
709
710 #[test]
711 fn resolve_shape_google_picks_8on22() {
712 let s = resolve_shape("gemini-3-flash");
713 assert_eq!(s.name, "8on22-bw");
714 }
715
716 #[test]
717 fn resolve_shape_unknown_defaults_to_anthropic() {
718 let s = resolve_shape("unknown-provider-model-xyz");
719 assert_eq!(s.name, "11on16-bw");
720 }
721
722 #[test]
723 fn chars_per_frame_scales_with_frame_size_and_cell() {
724 let s = lookup_owned("11on16-bw").unwrap();
726 assert_eq!(s.chars_per_frame(), 1568 / 11);
727 }
728
729 #[test]
730 fn normalize_strips_ansi() {
731 let input = "\u{1b}[31mhello\u{1b}[0m world";
732 let out = normalize(input);
733 assert!(!out.contains('\u{1b}'));
734 assert!(out.contains("hello"));
735 assert!(out.contains("world"));
736 }
737
738 #[test]
739 fn normalize_folds_newlines_to_full_block() {
740 let input = "line1\n\n\nline2";
741 let out = normalize(input);
742 assert!(out.contains('\u{2588}'));
743 assert!(!out.contains("\n\n\n"));
744 }
745
746 #[test]
747 fn normalize_replaces_box_drawing_with_ascii() {
748 let input = "┌──┐\n│hi│\n└──┘";
749 let out = normalize(input);
750 assert!(!out.contains('┌'));
751 assert!(out.contains('-'));
752 }
753
754 #[test]
755 fn normalize_collapses_whitespace_runs() {
756 let input = "a b\t\tc";
757 let out = normalize(input);
758 assert!(!out.contains(" "));
759 assert!(!out.contains('\t'));
760 assert_eq!(out, "a b c");
761 }
762
763 #[test]
764 fn prepare_respects_text_limit() {
765 let long = "x".repeat(10_000);
766 let prep = prepare(&long, 2000, 200);
767 assert!(prep.bounded_text.chars().count() <= 200);
768 assert!(prep.remaining_text.chars().count() >= 10_000 - 200);
769 }
770
771 #[test]
772 fn prepare_short_input_has_no_remaining() {
773 let prep = prepare("hello world", 2000, 200);
774 assert_eq!(prep.bounded_text, "hello world\n");
775 assert!(prep.remaining_text.is_empty());
776 }
777
778 #[test]
779 fn serialize_conversation_truncates_tool_results() {
780 let input = "Tool: a very long output that goes on and on and on";
781 let text = serialize_conversation(input, 20);
782 assert!(text.contains("[truncated]"));
783 assert!(text.chars().count() < 60);
784 }
785
786 #[test]
787 fn compact_emits_one_frame_per_chunk_with_png_bytes() {
788 let long: String = "a".repeat(800);
794 let prep = prepare(&long, 2000, 800);
795 let opts = CompactOptions {
796 model_id: "claude-3-5-sonnet".into(),
797 ..Default::default()
798 };
799 let result = compact(&prep, &opts);
800 assert!(!result.frames.is_empty());
801 assert!(result.frames.len() <= opts.max_frames as usize);
802 for f in &result.frames {
803 assert!(
804 !f.bytes.is_empty(),
805 "frame {} should have real PNG bytes",
806 f.index
807 );
808 assert_eq!(
809 &f.bytes[..8],
810 &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
811 "frame {} missing PNG magic",
812 f.index
813 );
814 }
815 }
816
817 #[test]
818 fn compact_summary_includes_lead_in_and_head() {
819 let text = "User: hello\nAssistant: hi";
820 let prep = prepare(text, 2000, 2000);
821 let opts = CompactOptions {
822 model_id: "claude".into(),
823 ..Default::default()
824 };
825 let result = compact(&prep, &opts);
826 assert!(result.summary.starts_with("Resume prior conversation."));
827 }
828 #[test]
829 fn compact_frame_ranges_are_disjoint_and_cover_source() {
830 let long: String = (0..200).map(|i| format!("x{i}\n")).collect();
834 let prep = prepare(&long, 5000, 5000);
835 let opts = CompactOptions {
836 model_id: "claude".into(),
837 max_frames: 5,
838 ..Default::default()
839 };
840 let result = compact(&prep, &opts);
841 assert!(!result.frames.is_empty());
842 let mut prev_end = 0;
844 for (i, f) in result.frames.iter().enumerate() {
845 assert_eq!(f.index, i as u32);
846 assert_eq!(f.source_start, prev_end);
847 assert!(f.source_end > f.source_start);
848 prev_end = f.source_end;
849 }
850 let chars_per_frame = result.shape.chars_per_frame() as usize;
854 assert_eq!(prev_end, (opts.max_frames as usize) * chars_per_frame);
855 }
856
857 #[test]
858 fn bounded_source_drop_is_lossless() {
859 let text = "x".repeat(2000);
860 let prep = prepare(&text, 5000, 2000);
861 let opts = CompactOptions {
862 model_id: "claude".into(),
863 ..Default::default()
864 };
865 let result = compact(&prep, &opts);
866 let bounded = result.into_bounded_source();
867 assert_eq!(bounded.text, prep.bounded_text);
868 }
869
870 #[test]
871 fn compact_renders_real_png_bytes_per_frame() {
872 let long: String = "a".repeat(500);
877 let prep = prepare(&long, 2000, 500);
878 let opts = CompactOptions {
879 model_id: "claude".into(),
880 max_frames: 3,
881 ..Default::default()
882 };
883 let result = compact(&prep, &opts);
884 assert!(!result.frames.is_empty(), "compact must produce ≥1 frame");
885 for f in &result.frames {
886 assert!(!f.bytes.is_empty(), "frame {} has empty bytes", f.index);
887 assert_eq!(
889 &f.bytes[..8],
890 &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
891 "frame {} is not a PNG",
892 f.index
893 );
894 }
895 }
896}