1use super::*;
2
3impl Context {
4 pub fn big_text(&mut self, s: impl Into<String>) -> Response {
6 let text = s.into();
7 if text.is_empty() {
8 return Response::none();
9 }
10 let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
11 let total_width = (glyphs.len() as u32).saturating_mul(8);
12 let on_color = self.theme.primary;
13
14 let response = self.interaction();
15 self.container().w(total_width).h(4).draw(move |buf, rect| {
16 if rect.width == 0 || rect.height == 0 {
17 return;
18 }
19
20 for (glyph_idx, glyph) in glyphs.iter().enumerate() {
21 let base_x = rect.x + (glyph_idx as u32) * 8;
22 if base_x >= rect.right() {
23 break;
24 }
25
26 for pair in 0..4usize {
27 let y = rect.y + pair as u32;
28 if y >= rect.bottom() {
29 continue;
30 }
31
32 let upper = glyph[pair * 2];
33 let lower = glyph[pair * 2 + 1];
34
35 for bit in 0..8u32 {
36 let x = base_x + bit;
37 if x >= rect.right() {
38 break;
39 }
40
41 let mask = 1u8 << (bit as u8);
42 let upper_on = (upper & mask) != 0;
43 let lower_on = (lower & mask) != 0;
44 let (ch, fg, bg) = match (upper_on, lower_on) {
45 (true, true) => ('█', on_color, on_color),
46 (true, false) => ('▀', on_color, Color::Reset),
47 (false, true) => ('▄', on_color, Color::Reset),
48 (false, false) => (' ', Color::Reset, Color::Reset),
49 };
50 buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
51 }
52 }
53 }
54 });
55
56 response
57 }
58
59 pub fn image(&mut self, img: &HalfBlockImage) -> Response {
81 let (w, h) = (img.width, img.height);
82 let Some(pixels) = prepare_halfblock(&img.pixels, w, h) else {
83 return Response::none();
84 };
85 let response = self.interaction();
86 self.container().w(w).h(h).draw(move |buf, rect| {
87 for row in 0..h {
88 for col in 0..w {
89 if let Some(&(fg, bg)) = pixels.get((row * w + col) as usize) {
90 buf.set_char(rect.x + col, rect.y + row, '▀', Style::new().fg(fg).bg(bg));
91 }
92 }
93 }
94 });
95
96 response
97 }
98
99 pub fn kitty_image(
117 &mut self,
118 rgba: &[u8],
119 pixel_width: u32,
120 pixel_height: u32,
121 cols: u32,
122 rows: u32,
123 ) -> Response {
124 if cols == 0 || rows == 0 {
125 return Response::none();
126 }
127 if !self.kitty_graphics_supported() {
128 return self.rgba_halfblock_fallback(
129 rgba,
130 pixel_width,
131 pixel_height,
132 cols,
133 rows,
134 "[kitty unsupported]",
135 );
136 }
137
138 let Some((content_hash, rgba_arc)) = prepare_rgba(rgba, pixel_width, pixel_height) else {
139 return self.rgba_halfblock_fallback(
140 rgba,
141 pixel_width,
142 pixel_height,
143 cols,
144 rows,
145 "[kitty invalid]",
146 );
147 };
148 let sw = pixel_width;
149 let sh = pixel_height;
150
151 let response = self.interaction();
152 self.container().w(cols).h(rows).draw(move |buf, rect| {
153 if rect.width == 0 || rect.height == 0 {
154 return;
155 }
156 buf.kitty_place(crate::buffer::KittyPlacement {
157 content_hash,
158 rgba: rgba_arc.clone(),
159 src_width: sw,
160 src_height: sh,
161 x: rect.x,
162 y: rect.y,
163 cols: rect.width,
164 rows: rect.height,
165 crop_y: 0,
166 crop_h: 0,
167 });
168 });
169 response
170 }
171
172 pub fn kitty_image_fit(
184 &mut self,
185 rgba: &[u8],
186 src_width: u32,
187 src_height: u32,
188 cols: u32,
189 ) -> Response {
190 if cols == 0 {
191 return Response::none();
192 }
193 let supported = self.kitty_graphics_supported();
194 #[cfg(feature = "crossterm")]
195 let (cell_w, cell_h) = if supported {
196 crate::terminal::cell_pixel_size()
197 } else {
198 (8u32, 16u32)
199 };
200 #[cfg(not(feature = "crossterm"))]
201 let (cell_w, cell_h) = (8u32, 16u32);
202
203 let rows = image_fit_rows(src_width, src_height, cols, cell_w, cell_h);
204 if !supported {
205 return self.rgba_halfblock_fallback(
206 rgba,
207 src_width,
208 src_height,
209 cols,
210 rows,
211 "[kitty unsupported]",
212 );
213 }
214
215 let Some((content_hash, rgba_arc)) = prepare_rgba(rgba, src_width, src_height) else {
216 return self.rgba_halfblock_fallback(
217 rgba,
218 src_width,
219 src_height,
220 cols,
221 rows,
222 "[kitty invalid]",
223 );
224 };
225 let sw = src_width;
226 let sh = src_height;
227
228 let response = self.interaction();
229 self.container().w(cols).h(rows).draw(move |buf, rect| {
230 if rect.width == 0 || rect.height == 0 {
231 return;
232 }
233 buf.kitty_place(crate::buffer::KittyPlacement {
234 content_hash,
235 rgba: rgba_arc.clone(),
236 src_width: sw,
237 src_height: sh,
238 x: rect.x,
239 y: rect.y,
240 cols: rect.width,
241 rows: rect.height,
242 crop_y: 0,
243 crop_h: 0,
244 });
245 });
246 response
247 }
248
249 #[cfg(feature = "crossterm")]
268 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
269 pub fn sixel_image(
270 &mut self,
271 rgba: &[u8],
272 pixel_width: u32,
273 pixel_height: u32,
274 cols: u32,
275 rows: u32,
276 ) -> Response {
277 if cols == 0 || rows == 0 {
278 return Response::none();
279 }
280 let sixel_supported = self.sixel_supported();
286 if !sixel_supported {
287 let response = self.interaction();
288 self.container().w(cols).h(rows).draw(|buf, rect| {
289 if rect.width == 0 || rect.height == 0 {
290 return;
291 }
292 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
293 });
294 return response;
295 }
296
297 let Some((content_hash, encoded)) = prepare_sixel(rgba, pixel_width, pixel_height, 256)
298 else {
299 let response = self.interaction();
300 self.container().w(cols).h(rows).draw(|buf, rect| {
301 if rect.width == 0 || rect.height == 0 {
302 return;
303 }
304 buf.set_string(rect.x, rect.y, "[sixel invalid]", Style::new());
305 });
306 return response;
307 };
308
309 let response = self.interaction();
314 self.container().w(cols).h(rows).draw(move |buf, rect| {
315 if rect.width == 0 || rect.height == 0 {
316 return;
317 }
318 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
319 buf.sprixel_place(crate::buffer::SprixelPlacement {
320 content_hash,
321 seq: encoded,
322 x: rect.x,
323 y: rect.y,
324 cols: rect.width,
325 rows: rect.height,
326 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
327 });
328 });
329 response
330 }
331
332 #[cfg(feature = "crossterm")]
355 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
356 pub fn iterm_image(&mut self, data: &[u8], cols: u32, rows: u32) -> Response {
357 if cols == 0 || rows == 0 {
358 return Response::none();
359 }
360 let supported = self.iterm_supported();
364 if !supported {
365 return self.iterm_placeholder(cols, rows, "[iterm2 unsupported]");
366 }
367
368 let Some((content_hash, encoded)) = prepare_iterm(data, cols, rows, false) else {
369 return self.iterm_placeholder(cols, rows, "[iterm2 invalid]");
370 };
371
372 let response = self.interaction();
373 self.container().w(cols).h(rows).draw(move |buf, rect| {
374 if rect.width == 0 || rect.height == 0 {
375 return;
376 }
377 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
378 buf.sprixel_place(crate::buffer::SprixelPlacement {
379 content_hash,
380 seq: encoded,
381 x: rect.x,
382 y: rect.y,
383 cols: rect.width,
384 rows: rect.height,
385 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
386 });
387 });
388 response
389 }
390
391 #[cfg(feature = "crossterm")]
410 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
411 pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
412 if cols == 0 {
413 return Response::none();
414 }
415 let supported = self.iterm_supported();
416
417 let (cell_w, cell_h) = if supported {
418 crate::terminal::cell_pixel_size()
419 } else {
420 (8u32, 16u32)
421 };
422 let dimensions = encoded_image_dimensions(data);
423 if supported && dimensions.is_none() {
424 return self.iterm_placeholder(cols, 1, "[iterm2 invalid]");
425 }
426 let (src_width, src_height) = dimensions.unwrap_or((1, 1));
427 let rows = image_fit_rows(src_width, src_height, cols, cell_w, cell_h);
428
429 if !supported {
430 return self.iterm_placeholder(cols, rows, "[iterm2 unsupported]");
431 };
432
433 let Some((content_hash, encoded)) = prepare_iterm(data, cols, 0, true) else {
435 return self.iterm_placeholder(cols, rows, "[iterm2 invalid]");
436 };
437
438 let response = self.interaction();
439 self.container().w(cols).h(rows).draw(move |buf, rect| {
440 if rect.width == 0 || rect.height == 0 {
441 return;
442 }
443 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
444 buf.sprixel_place(crate::buffer::SprixelPlacement {
445 content_hash,
446 seq: encoded,
447 x: rect.x,
448 y: rect.y,
449 cols: rect.width,
450 rows: rect.height,
451 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
452 });
453 });
454 response
455 }
456
457 #[cfg(feature = "crossterm")]
458 fn kitty_graphics_supported(&self) -> bool {
459 if !self.is_real_terminal {
460 return false;
461 }
462 if terminal_force_graphics("SLT_FORCE_KITTY") {
463 return true;
464 }
465 if terminal_graphics_blocked_by_multiplexer() {
466 return false;
467 }
468 self.capabilities.kitty_graphics || terminal_supports_kitty()
469 }
470
471 #[cfg(not(feature = "crossterm"))]
472 fn kitty_graphics_supported(&self) -> bool {
473 false
474 }
475
476 #[cfg(feature = "crossterm")]
477 fn sixel_supported(&self) -> bool {
478 if !self.is_real_terminal {
479 return false;
480 }
481 if terminal_force_graphics("SLT_FORCE_SIXEL") {
482 return true;
483 }
484 if terminal_graphics_blocked_by_multiplexer() {
485 return false;
486 }
487 self.capabilities.sixel || terminal_supports_sixel()
488 }
489
490 #[cfg(feature = "crossterm")]
491 fn iterm_supported(&self) -> bool {
492 if !self.is_real_terminal {
493 return false;
494 }
495 if terminal_force_graphics("SLT_FORCE_ITERM") {
496 return true;
497 }
498 if terminal_graphics_blocked_by_multiplexer() {
499 return false;
500 }
501 self.capabilities.iterm2 || terminal_supports_iterm()
502 }
503
504 fn rgba_halfblock_fallback(
505 &mut self,
506 rgba: &[u8],
507 pixel_width: u32,
508 pixel_height: u32,
509 cols: u32,
510 rows: u32,
511 placeholder: &'static str,
512 ) -> Response {
513 if cols == 0 || rows == 0 {
514 return Response::none();
515 }
516 let Some((_content_hash, rgba_data)) = prepare_rgba(rgba, pixel_width, pixel_height) else {
517 let response = self.interaction();
518 self.container().w(cols).h(rows).draw(move |buf, rect| {
519 if rect.width == 0 || rect.height == 0 {
520 return;
521 }
522 buf.set_string(rect.x, rect.y, placeholder, Style::new());
523 });
524 return response;
525 };
526
527 let response = self.interaction();
528 self.container().w(cols).h(rows).draw(move |buf, rect| {
529 if rect.width == 0 || rect.height == 0 {
530 return;
531 }
532
533 let dst_pixel_height = rect.height.saturating_mul(2).max(1);
534 for row in 0..rect.height {
535 for col in 0..rect.width {
536 let upper = sample_rgba_color(
537 rgba_data.as_slice(),
538 pixel_width,
539 pixel_height,
540 col,
541 row * 2,
542 rect.width,
543 dst_pixel_height,
544 );
545 let lower = sample_rgba_color(
546 rgba_data.as_slice(),
547 pixel_width,
548 pixel_height,
549 col,
550 row.saturating_mul(2).saturating_add(1),
551 rect.width,
552 dst_pixel_height,
553 );
554 draw_halfblock_cell(buf, rect.x + col, rect.y + row, upper, lower);
555 }
556 }
557 });
558 response
559 }
560
561 #[cfg(feature = "crossterm")]
564 fn iterm_placeholder(&mut self, cols: u32, rows: u32, placeholder: &'static str) -> Response {
565 if cols == 0 || rows == 0 {
566 return Response::none();
567 }
568 let response = self.interaction();
569 self.container().w(cols).h(rows).draw(move |buf, rect| {
570 if rect.width == 0 || rect.height == 0 {
571 return;
572 }
573 buf.set_string(rect.x, rect.y, placeholder, Style::new());
574 });
575 response
576 }
577
578 #[cfg(not(feature = "crossterm"))]
580 pub fn iterm_image(&mut self, _data: &[u8], cols: u32, rows: u32) -> Response {
581 if cols == 0 || rows == 0 {
582 return Response::none();
583 }
584 let response = self.interaction();
585 self.container().w(cols).h(rows).draw(|buf, rect| {
586 if rect.width == 0 || rect.height == 0 {
587 return;
588 }
589 buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
590 });
591 response
592 }
593
594 #[cfg(not(feature = "crossterm"))]
596 pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
597 if cols == 0 {
598 return Response::none();
599 }
600 let (src_width, src_height) = encoded_image_dimensions(data).unwrap_or((1, 1));
601 let rows = image_fit_rows(src_width, src_height, cols, 8, 16);
602 let response = self.interaction();
603 self.container().w(cols).h(rows).draw(|buf, rect| {
604 if rect.width == 0 || rect.height == 0 {
605 return;
606 }
607 buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
608 });
609 response
610 }
611
612 #[cfg(not(feature = "crossterm"))]
614 pub fn sixel_image(
615 &mut self,
616 _rgba: &[u8],
617 _pixel_width: u32,
618 _pixel_height: u32,
619 cols: u32,
620 rows: u32,
621 ) -> Response {
622 if cols == 0 || rows == 0 {
623 return Response::none();
624 }
625 let response = self.interaction();
626 self.container().w(cols).h(rows).draw(|buf, rect| {
627 if rect.width == 0 || rect.height == 0 {
628 return;
629 }
630 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
631 });
632 response
633 }
634
635 pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
651 if state.streaming {
652 state.cursor_tick = state.cursor_tick.wrapping_add(1);
653 state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
654 }
655
656 if state.content.is_empty() && state.streaming {
657 let cursor = if state.cursor_visible { "▌" } else { " " };
658 let primary = self.theme.primary;
659 self.text(cursor).fg(primary);
660 return Response::none();
661 }
662
663 if !state.content.is_empty() {
664 self.text(&state.content).wrap();
665 if state.streaming && state.cursor_visible {
666 let primary = self.theme.primary;
667 self.styled("▌", Style::new().fg(primary));
668 }
669 }
670
671 Response::none()
672 }
673
674 pub fn streaming_markdown(
692 &mut self,
693 state: &mut crate::widgets::StreamingMarkdownState,
694 ) -> Response {
695 if state.streaming {
696 state.cursor_tick = state.cursor_tick.wrapping_add(1);
697 state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
698 }
699
700 if state.content.is_empty() && state.streaming {
701 let cursor = if state.cursor_visible { "▌" } else { " " };
702 let primary = self.theme.primary;
703 self.text(cursor).fg(primary);
704 return Response::none();
705 }
706
707 let show_cursor = state.streaming && state.cursor_visible;
708 let trailing_newline = state.content.ends_with('\n');
709 let lines: Vec<&str> = state.content.lines().collect();
710 let last_line_index = lines.len().saturating_sub(1);
711
712 self.commands
713 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
714 direction: Direction::Column,
715 gap: 0,
716 align: Align::Start,
717 align_self: None,
718 justify: Justify::Start,
719 border: None,
720 border_sides: BorderSides::all(),
721 border_style: Style::new().fg(self.theme.border),
722 bg_color: None,
723 padding: Padding::default(),
724 margin: Margin::default(),
725 constraints: Constraints::default(),
726 title: None,
727 grow: 0,
728 group_name: None,
729 })));
730 self.skip_interaction_slot();
731
732 let text_style = Style::new().fg(self.theme.text);
733 let bold_style = Style::new().fg(self.theme.text).bold();
734 let code_style = Style::new().fg(self.theme.accent);
735 let border_style = Style::new().fg(self.theme.border).dim();
736
737 let mut in_code_block = false;
738 let mut code_block_lang = String::new();
739
740 for (idx, line) in lines.iter().enumerate() {
741 let line = *line;
742 let trimmed = line.trim();
743 let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
744 let cursor = if append_cursor { "▌" } else { "" };
745
746 if in_code_block {
747 if trimmed.starts_with("```") {
748 in_code_block = false;
749 code_block_lang.clear();
750 let mut line = String::from(" └────");
751 line.push_str(cursor);
752 self.styled(line, border_style);
753 } else {
754 self.line(|ui| {
755 ui.text(" ");
756 render_highlighted_line(ui, line);
757 if !cursor.is_empty() {
758 ui.styled(cursor, Style::new().fg(ui.theme.primary));
759 }
760 });
761 }
762 continue;
763 }
764
765 if trimmed.is_empty() {
766 if append_cursor {
767 self.styled("▌", Style::new().fg(self.theme.primary));
768 } else {
769 self.text(" ");
770 }
771 continue;
772 }
773
774 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
775 let mut line = "─".repeat(40);
776 line.push_str(cursor);
777 self.styled(line, border_style);
778 continue;
779 }
780
781 if let Some(heading) = trimmed.strip_prefix("### ") {
782 let mut line = String::with_capacity(heading.len() + cursor.len());
783 line.push_str(heading);
784 line.push_str(cursor);
785 self.styled(line, Style::new().bold().fg(self.theme.accent));
786 continue;
787 }
788
789 if let Some(heading) = trimmed.strip_prefix("## ") {
790 let mut line = String::with_capacity(heading.len() + cursor.len());
791 line.push_str(heading);
792 line.push_str(cursor);
793 self.styled(line, Style::new().bold().fg(self.theme.secondary));
794 continue;
795 }
796
797 if let Some(heading) = trimmed.strip_prefix("# ") {
798 let mut line = String::with_capacity(heading.len() + cursor.len());
799 line.push_str(heading);
800 line.push_str(cursor);
801 self.styled(line, Style::new().bold().fg(self.theme.primary));
802 continue;
803 }
804
805 if let Some(code) = trimmed.strip_prefix("```") {
806 in_code_block = true;
807 code_block_lang = code.trim().to_string();
808 let label = if code_block_lang.is_empty() {
809 "code".to_string()
810 } else {
811 let mut label = String::from("code:");
812 label.push_str(&code_block_lang);
813 label
814 };
815 let mut line = String::with_capacity(5 + label.len() + cursor.len());
816 line.push_str(" ┌─");
817 line.push_str(&label);
818 line.push('─');
819 line.push_str(cursor);
820 self.styled(line, border_style);
821 continue;
822 }
823
824 if let Some(item) = trimmed
825 .strip_prefix("- ")
826 .or_else(|| trimmed.strip_prefix("* "))
827 {
828 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
829 if segs.len() <= 1 {
830 let mut line = String::with_capacity(4 + item.len() + cursor.len());
831 line.push_str(" • ");
832 line.push_str(item);
833 line.push_str(cursor);
834 self.styled(line, text_style);
835 } else {
836 self.line(|ui| {
837 ui.styled(" • ", text_style);
838 for (s, st) in segs {
839 ui.styled(s, st);
840 }
841 if append_cursor {
842 ui.styled("▌", Style::new().fg(ui.theme.primary));
843 }
844 });
845 }
846 continue;
847 }
848
849 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
850 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
851 if parts.len() == 2 {
852 let segs =
853 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
854 if segs.len() <= 1 {
855 let mut line = String::with_capacity(
856 4 + parts[0].len() + parts[1].len() + cursor.len(),
857 );
858 line.push_str(" ");
859 line.push_str(parts[0]);
860 line.push_str(". ");
861 line.push_str(parts[1]);
862 line.push_str(cursor);
863 self.styled(line, text_style);
864 } else {
865 self.line(|ui| {
866 let mut prefix = String::with_capacity(4 + parts[0].len());
867 prefix.push_str(" ");
868 prefix.push_str(parts[0]);
869 prefix.push_str(". ");
870 ui.styled(prefix, text_style);
871 for (s, st) in segs {
872 ui.styled(s, st);
873 }
874 if append_cursor {
875 ui.styled("▌", Style::new().fg(ui.theme.primary));
876 }
877 });
878 }
879 } else {
880 let mut line = String::with_capacity(trimmed.len() + cursor.len());
881 line.push_str(trimmed);
882 line.push_str(cursor);
883 self.styled(line, text_style);
884 }
885 continue;
886 }
887
888 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
889 if segs.len() <= 1 {
890 let mut line = String::with_capacity(trimmed.len() + cursor.len());
891 line.push_str(trimmed);
892 line.push_str(cursor);
893 self.styled(line, text_style);
894 } else {
895 self.line(|ui| {
896 for (s, st) in segs {
897 ui.styled(s, st);
898 }
899 if append_cursor {
900 ui.styled("▌", Style::new().fg(ui.theme.primary));
901 }
902 });
903 }
904 }
905
906 if show_cursor && trailing_newline {
907 if in_code_block {
908 self.styled(" ▌", code_style);
909 } else {
910 self.styled("▌", Style::new().fg(self.theme.primary));
911 }
912 }
913
914 if state.in_code_block != in_code_block {
915 state.in_code_block = in_code_block;
916 }
917 if state.code_block_lang != code_block_lang {
918 state.code_block_lang = code_block_lang;
919 }
920
921 self.commands.push(Command::EndContainer);
922 self.rollback.last_text_idx = None;
923 Response::none()
924 }
925
926 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
941 let old_action = state.action;
942 let theme = self.theme;
943 let _ = self.bordered(Border::Rounded).col(|ui| {
944 let _ = ui.row(|ui| {
945 ui.text("⚡").fg(theme.warning);
946 ui.text(&state.tool_name).bold().fg(theme.primary);
947 });
948 ui.text(&state.description).dim();
949
950 if state.action == ApprovalAction::Pending {
951 let _ = ui.row(|ui| {
952 if ui.button("✓ Approve").clicked {
953 state.action = ApprovalAction::Approved;
954 }
955 if ui.button("✗ Reject").clicked {
956 state.action = ApprovalAction::Rejected;
957 }
958 });
959 } else {
960 let (label, color) = match state.action {
961 ApprovalAction::Approved => ("✓ Approved", theme.success),
962 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
963 ApprovalAction::Pending => unreachable!(),
964 };
965 ui.text(label).fg(color).bold();
966 }
967 });
968
969 Response {
970 changed: state.action != old_action,
971 ..Response::none()
972 }
973 }
974
975 pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
988 if items.is_empty() {
989 return Response::none();
990 }
991
992 let theme = self.theme;
993 let total: usize = items.iter().map(|item| item.tokens).sum();
994
995 let _ = self.container().row(|ui| {
996 ui.text("📎").dim();
997 for item in items {
998 let token_count = format_token_count(item.tokens);
999 let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
1000 line.push_str(&item.label);
1001 line.push_str(" (");
1002 line.push_str(&token_count);
1003 line.push(')');
1004 ui.text(line).fg(theme.secondary);
1005 }
1006 ui.spacer();
1007 let total_text = format_token_count(total);
1008 let mut line = String::with_capacity(2 + total_text.len());
1009 line.push_str("Σ ");
1010 line.push_str(&total_text);
1011 ui.text(line).dim();
1012 });
1013
1014 Response::none()
1015 }
1016}
1017
1018#[cfg(test)]
1019mod media_response_tests {
1020 use crate::{Response, TestBackend};
1021
1022 #[test]
1023 fn big_text_returns_warm_frame_rect() {
1024 let mut backend = TestBackend::new(40, 8);
1025 let mut response = Response::none();
1026 backend.render(|ui| response = ui.big_text("A"));
1027 backend.render(|ui| response = ui.big_text("A"));
1028 assert_eq!(response.rect.width, 8);
1029 assert_eq!(response.rect.height, 4);
1030 }
1031
1032 #[test]
1033 fn unsupported_iterm_placeholder_returns_warm_frame_rect() {
1034 let mut png = vec![0u8; 24];
1035 png[..8].copy_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
1036 png[12..16].copy_from_slice(b"IHDR");
1037 png[16..20].copy_from_slice(&100u32.to_be_bytes());
1038 png[20..24].copy_from_slice(&50u32.to_be_bytes());
1039
1040 let mut backend = TestBackend::new(40, 8);
1041 let mut response = Response::none();
1042 backend.render(|ui| response = ui.iterm_image_fit(&png, 20));
1043 backend.render(|ui| response = ui.iterm_image_fit(&png, 20));
1044 assert!(response.rect.width > 0);
1045 assert!(response.rect.height > 0);
1046 backend.assert_contains("[iterm2 unsupported]");
1047 }
1048
1049 #[test]
1050 fn empty_media_returns_none() {
1051 let mut backend = TestBackend::new(20, 4);
1052 let mut response = Response::none();
1053 backend.render(|ui| response = ui.big_text(""));
1054 assert_eq!(response.rect.width, 0);
1055 assert_eq!(response.rect.height, 0);
1056 }
1057}