1use super::*;
2
3impl Context {
4 pub fn big_text(&mut self, s: impl Into<String>) -> Response {
6 let text = s.into();
7 let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
8 let total_width = (glyphs.len() as u32).saturating_mul(8);
9 let on_color = self.theme.primary;
10
11 self.container().w(total_width).h(4).draw(move |buf, rect| {
12 if rect.width == 0 || rect.height == 0 {
13 return;
14 }
15
16 for (glyph_idx, glyph) in glyphs.iter().enumerate() {
17 let base_x = rect.x + (glyph_idx as u32) * 8;
18 if base_x >= rect.right() {
19 break;
20 }
21
22 for pair in 0..4usize {
23 let y = rect.y + pair as u32;
24 if y >= rect.bottom() {
25 continue;
26 }
27
28 let upper = glyph[pair * 2];
29 let lower = glyph[pair * 2 + 1];
30
31 for bit in 0..8u32 {
32 let x = base_x + bit;
33 if x >= rect.right() {
34 break;
35 }
36
37 let mask = 1u8 << (bit as u8);
38 let upper_on = (upper & mask) != 0;
39 let lower_on = (lower & mask) != 0;
40 let (ch, fg, bg) = match (upper_on, lower_on) {
41 (true, true) => ('█', on_color, on_color),
42 (true, false) => ('▀', on_color, Color::Reset),
43 (false, true) => ('▄', on_color, Color::Reset),
44 (false, false) => (' ', Color::Reset, Color::Reset),
45 };
46 buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
47 }
48 }
49 }
50 });
51
52 Response::none()
53 }
54
55 pub fn image(&mut self, img: &HalfBlockImage) -> Response {
77 let pixels: Vec<(Color, Color)> = img.pixels.clone();
78 let (w, h) = (img.width, img.height);
79 self.container().w(w).h(h).draw(move |buf, rect| {
80 for row in 0..h {
81 for col in 0..w {
82 if let Some(&(fg, bg)) = pixels.get((row * w + col) as usize) {
83 buf.set_char(rect.x + col, rect.y + row, '▀', Style::new().fg(fg).bg(bg));
84 }
85 }
86 }
87 });
88
89 Response::none()
90 }
91
92 pub fn kitty_image(
108 &mut self,
109 rgba: &[u8],
110 pixel_width: u32,
111 pixel_height: u32,
112 cols: u32,
113 rows: u32,
114 ) -> Response {
115 let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
116 let content_hash = crate::buffer::hash_rgba(&rgba_data);
117 let rgba_arc = std::sync::Arc::new(rgba_data);
118 let sw = pixel_width;
119 let sh = pixel_height;
120
121 self.container().w(cols).h(rows).draw(move |buf, rect| {
122 if rect.width == 0 || rect.height == 0 {
123 return;
124 }
125 buf.kitty_place(crate::buffer::KittyPlacement {
126 content_hash,
127 rgba: rgba_arc.clone(),
128 src_width: sw,
129 src_height: sh,
130 x: rect.x,
131 y: rect.y,
132 cols: rect.width,
133 rows: rect.height,
134 crop_y: 0,
135 crop_h: 0,
136 });
137 });
138 Response::none()
139 }
140
141 pub fn kitty_image_fit(
151 &mut self,
152 rgba: &[u8],
153 src_width: u32,
154 src_height: u32,
155 cols: u32,
156 ) -> Response {
157 #[cfg(feature = "crossterm")]
158 let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
159 #[cfg(not(feature = "crossterm"))]
160 let (cell_w, cell_h) = (8u32, 16u32);
161
162 let rows = if src_width == 0 {
163 1
164 } else {
165 ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
166 .ceil()
167 .max(1.0) as u32
168 };
169 let rgba_data = normalize_rgba(rgba, src_width, src_height);
170 let content_hash = crate::buffer::hash_rgba(&rgba_data);
171 let rgba_arc = std::sync::Arc::new(rgba_data);
172 let sw = src_width;
173 let sh = src_height;
174
175 self.container().w(cols).h(rows).draw(move |buf, rect| {
176 if rect.width == 0 || rect.height == 0 {
177 return;
178 }
179 buf.kitty_place(crate::buffer::KittyPlacement {
180 content_hash,
181 rgba: rgba_arc.clone(),
182 src_width: sw,
183 src_height: sh,
184 x: rect.x,
185 y: rect.y,
186 cols: rect.width,
187 rows: rect.height,
188 crop_y: 0,
189 crop_h: 0,
190 });
191 });
192 Response::none()
193 }
194
195 #[cfg(feature = "crossterm")]
214 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
215 pub fn sixel_image(
216 &mut self,
217 rgba: &[u8],
218 pixel_width: u32,
219 pixel_height: u32,
220 cols: u32,
221 rows: u32,
222 ) -> Response {
223 let sixel_supported =
229 self.is_real_terminal && (self.capabilities.sixel || terminal_supports_sixel());
230 if !sixel_supported {
231 self.container().w(cols).h(rows).draw(|buf, rect| {
232 if rect.width == 0 || rect.height == 0 {
233 return;
234 }
235 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
236 });
237 return Response::none();
238 }
239
240 let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
241 let content_hash = crate::buffer::hash_rgba(&rgba);
242 let encoded = crate::sixel::encode_sixel(&rgba, pixel_width, pixel_height, 256);
243
244 if encoded.is_empty() {
245 self.container().w(cols).h(rows).draw(|buf, rect| {
246 if rect.width == 0 || rect.height == 0 {
247 return;
248 }
249 buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
250 });
251 return Response::none();
252 }
253
254 self.container().w(cols).h(rows).draw(move |buf, rect| {
259 if rect.width == 0 || rect.height == 0 {
260 return;
261 }
262 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
263 buf.sprixel_place(crate::buffer::SprixelPlacement {
264 content_hash,
265 seq: encoded,
266 x: rect.x,
267 y: rect.y,
268 cols: rect.width,
269 rows: rect.height,
270 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
271 });
272 });
273 Response::none()
274 }
275
276 #[cfg(feature = "crossterm")]
299 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
300 pub fn iterm_image(&mut self, data: &[u8], cols: u32, rows: u32) -> Response {
301 let supported =
305 self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
306 if !supported {
307 return self.iterm_placeholder(cols, rows);
308 }
309
310 let content_hash = crate::buffer::hash_rgba(data);
311 let encoded = crate::iterm::encode_iterm_osc1337(data, cols, rows, false);
312 if encoded.is_empty() {
313 return self.iterm_placeholder(cols, rows);
314 }
315
316 self.container().w(cols).h(rows).draw(move |buf, rect| {
317 if rect.width == 0 || rect.height == 0 {
318 return;
319 }
320 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
321 buf.sprixel_place(crate::buffer::SprixelPlacement {
322 content_hash,
323 seq: encoded,
324 x: rect.x,
325 y: rect.y,
326 cols: rect.width,
327 rows: rect.height,
328 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
329 });
330 });
331 Response::none()
332 }
333
334 #[cfg(feature = "crossterm")]
353 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
354 pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
355 let supported =
356 self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
357
358 let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
363 let rows = if cell_h == 0 {
364 cols.max(1)
365 } else {
366 ((cols as f64 * cell_w as f64) / cell_h as f64)
367 .ceil()
368 .max(1.0) as u32
369 };
370
371 if !supported {
372 return self.iterm_placeholder(cols, rows);
373 }
374
375 let content_hash = crate::buffer::hash_rgba(data);
376 let encoded = crate::iterm::encode_iterm_osc1337(data, cols, 0, true);
378 if encoded.is_empty() {
379 return self.iterm_placeholder(cols, rows);
380 }
381
382 self.container().w(cols).h(rows).draw(move |buf, rect| {
383 if rect.width == 0 || rect.height == 0 {
384 return;
385 }
386 let cells = (rect.width as usize).saturating_mul(rect.height as usize);
387 buf.sprixel_place(crate::buffer::SprixelPlacement {
388 content_hash,
389 seq: encoded,
390 x: rect.x,
391 y: rect.y,
392 cols: rect.width,
393 rows: rect.height,
394 cells: vec![crate::buffer::SprixelCell::Opaque; cells],
395 });
396 });
397 Response::none()
398 }
399
400 #[cfg(feature = "crossterm")]
403 fn iterm_placeholder(&mut self, cols: u32, rows: u32) -> Response {
404 self.container().w(cols).h(rows).draw(|buf, rect| {
405 if rect.width == 0 || rect.height == 0 {
406 return;
407 }
408 buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
409 });
410 Response::none()
411 }
412
413 #[cfg(not(feature = "crossterm"))]
415 pub fn iterm_image(&mut self, _data: &[u8], cols: u32, rows: u32) -> Response {
416 self.container().w(cols).h(rows).draw(|buf, rect| {
417 if rect.width == 0 || rect.height == 0 {
418 return;
419 }
420 buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
421 });
422 Response::none()
423 }
424
425 #[cfg(not(feature = "crossterm"))]
427 pub fn iterm_image_fit(&mut self, _data: &[u8], cols: u32) -> Response {
428 let rows = (cols / 2).max(1);
431 self.container().w(cols).h(rows).draw(|buf, rect| {
432 if rect.width == 0 || rect.height == 0 {
433 return;
434 }
435 buf.set_string(rect.x, rect.y, "[iterm2 unsupported]", Style::new());
436 });
437 Response::none()
438 }
439
440 #[cfg(not(feature = "crossterm"))]
442 pub fn sixel_image(
443 &mut self,
444 _rgba: &[u8],
445 _pixel_width: u32,
446 _pixel_height: u32,
447 cols: u32,
448 rows: u32,
449 ) -> Response {
450 self.container().w(cols).h(rows).draw(|buf, rect| {
451 if rect.width == 0 || rect.height == 0 {
452 return;
453 }
454 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
455 });
456 Response::none()
457 }
458
459 pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
475 if state.streaming {
476 state.cursor_tick = state.cursor_tick.wrapping_add(1);
477 state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
478 }
479
480 if state.content.is_empty() && state.streaming {
481 let cursor = if state.cursor_visible { "▌" } else { " " };
482 let primary = self.theme.primary;
483 self.text(cursor).fg(primary);
484 return Response::none();
485 }
486
487 if !state.content.is_empty() {
488 self.text(&state.content).wrap();
489 if state.streaming && state.cursor_visible {
490 let primary = self.theme.primary;
491 self.styled("▌", Style::new().fg(primary));
492 }
493 }
494
495 Response::none()
496 }
497
498 pub fn streaming_markdown(
516 &mut self,
517 state: &mut crate::widgets::StreamingMarkdownState,
518 ) -> Response {
519 if state.streaming {
520 state.cursor_tick = state.cursor_tick.wrapping_add(1);
521 state.cursor_visible = (state.cursor_tick / 8).is_multiple_of(2);
522 }
523
524 if state.content.is_empty() && state.streaming {
525 let cursor = if state.cursor_visible { "▌" } else { " " };
526 let primary = self.theme.primary;
527 self.text(cursor).fg(primary);
528 return Response::none();
529 }
530
531 let show_cursor = state.streaming && state.cursor_visible;
532 let trailing_newline = state.content.ends_with('\n');
533 let lines: Vec<&str> = state.content.lines().collect();
534 let last_line_index = lines.len().saturating_sub(1);
535
536 self.commands
537 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
538 direction: Direction::Column,
539 gap: 0,
540 align: Align::Start,
541 align_self: None,
542 justify: Justify::Start,
543 border: None,
544 border_sides: BorderSides::all(),
545 border_style: Style::new().fg(self.theme.border),
546 bg_color: None,
547 padding: Padding::default(),
548 margin: Margin::default(),
549 constraints: Constraints::default(),
550 title: None,
551 grow: 0,
552 group_name: None,
553 })));
554 self.skip_interaction_slot();
555
556 let text_style = Style::new().fg(self.theme.text);
557 let bold_style = Style::new().fg(self.theme.text).bold();
558 let code_style = Style::new().fg(self.theme.accent);
559 let border_style = Style::new().fg(self.theme.border).dim();
560
561 let mut in_code_block = false;
562 let mut code_block_lang = String::new();
563
564 for (idx, line) in lines.iter().enumerate() {
565 let line = *line;
566 let trimmed = line.trim();
567 let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
568 let cursor = if append_cursor { "▌" } else { "" };
569
570 if in_code_block {
571 if trimmed.starts_with("```") {
572 in_code_block = false;
573 code_block_lang.clear();
574 let mut line = String::from(" └────");
575 line.push_str(cursor);
576 self.styled(line, border_style);
577 } else {
578 self.line(|ui| {
579 ui.text(" ");
580 render_highlighted_line(ui, line);
581 if !cursor.is_empty() {
582 ui.styled(cursor, Style::new().fg(ui.theme.primary));
583 }
584 });
585 }
586 continue;
587 }
588
589 if trimmed.is_empty() {
590 if append_cursor {
591 self.styled("▌", Style::new().fg(self.theme.primary));
592 } else {
593 self.text(" ");
594 }
595 continue;
596 }
597
598 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
599 let mut line = "─".repeat(40);
600 line.push_str(cursor);
601 self.styled(line, border_style);
602 continue;
603 }
604
605 if let Some(heading) = trimmed.strip_prefix("### ") {
606 let mut line = String::with_capacity(heading.len() + cursor.len());
607 line.push_str(heading);
608 line.push_str(cursor);
609 self.styled(line, Style::new().bold().fg(self.theme.accent));
610 continue;
611 }
612
613 if let Some(heading) = trimmed.strip_prefix("## ") {
614 let mut line = String::with_capacity(heading.len() + cursor.len());
615 line.push_str(heading);
616 line.push_str(cursor);
617 self.styled(line, Style::new().bold().fg(self.theme.secondary));
618 continue;
619 }
620
621 if let Some(heading) = trimmed.strip_prefix("# ") {
622 let mut line = String::with_capacity(heading.len() + cursor.len());
623 line.push_str(heading);
624 line.push_str(cursor);
625 self.styled(line, Style::new().bold().fg(self.theme.primary));
626 continue;
627 }
628
629 if let Some(code) = trimmed.strip_prefix("```") {
630 in_code_block = true;
631 code_block_lang = code.trim().to_string();
632 let label = if code_block_lang.is_empty() {
633 "code".to_string()
634 } else {
635 let mut label = String::from("code:");
636 label.push_str(&code_block_lang);
637 label
638 };
639 let mut line = String::with_capacity(5 + label.len() + cursor.len());
640 line.push_str(" ┌─");
641 line.push_str(&label);
642 line.push('─');
643 line.push_str(cursor);
644 self.styled(line, border_style);
645 continue;
646 }
647
648 if let Some(item) = trimmed
649 .strip_prefix("- ")
650 .or_else(|| trimmed.strip_prefix("* "))
651 {
652 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
653 if segs.len() <= 1 {
654 let mut line = String::with_capacity(4 + item.len() + cursor.len());
655 line.push_str(" • ");
656 line.push_str(item);
657 line.push_str(cursor);
658 self.styled(line, text_style);
659 } else {
660 self.line(|ui| {
661 ui.styled(" • ", text_style);
662 for (s, st) in segs {
663 ui.styled(s, st);
664 }
665 if append_cursor {
666 ui.styled("▌", Style::new().fg(ui.theme.primary));
667 }
668 });
669 }
670 continue;
671 }
672
673 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
674 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
675 if parts.len() == 2 {
676 let segs =
677 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
678 if segs.len() <= 1 {
679 let mut line = String::with_capacity(
680 4 + parts[0].len() + parts[1].len() + cursor.len(),
681 );
682 line.push_str(" ");
683 line.push_str(parts[0]);
684 line.push_str(". ");
685 line.push_str(parts[1]);
686 line.push_str(cursor);
687 self.styled(line, text_style);
688 } else {
689 self.line(|ui| {
690 let mut prefix = String::with_capacity(4 + parts[0].len());
691 prefix.push_str(" ");
692 prefix.push_str(parts[0]);
693 prefix.push_str(". ");
694 ui.styled(prefix, text_style);
695 for (s, st) in segs {
696 ui.styled(s, st);
697 }
698 if append_cursor {
699 ui.styled("▌", Style::new().fg(ui.theme.primary));
700 }
701 });
702 }
703 } else {
704 let mut line = String::with_capacity(trimmed.len() + cursor.len());
705 line.push_str(trimmed);
706 line.push_str(cursor);
707 self.styled(line, text_style);
708 }
709 continue;
710 }
711
712 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
713 if segs.len() <= 1 {
714 let mut line = String::with_capacity(trimmed.len() + cursor.len());
715 line.push_str(trimmed);
716 line.push_str(cursor);
717 self.styled(line, text_style);
718 } else {
719 self.line(|ui| {
720 for (s, st) in segs {
721 ui.styled(s, st);
722 }
723 if append_cursor {
724 ui.styled("▌", Style::new().fg(ui.theme.primary));
725 }
726 });
727 }
728 }
729
730 if show_cursor && trailing_newline {
731 if in_code_block {
732 self.styled(" ▌", code_style);
733 } else {
734 self.styled("▌", Style::new().fg(self.theme.primary));
735 }
736 }
737
738 if state.in_code_block != in_code_block {
739 state.in_code_block = in_code_block;
740 }
741 if state.code_block_lang != code_block_lang {
742 state.code_block_lang = code_block_lang;
743 }
744
745 self.commands.push(Command::EndContainer);
746 self.rollback.last_text_idx = None;
747 Response::none()
748 }
749
750 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
765 let old_action = state.action;
766 let theme = self.theme;
767 let _ = self.bordered(Border::Rounded).col(|ui| {
768 let _ = ui.row(|ui| {
769 ui.text("⚡").fg(theme.warning);
770 ui.text(&state.tool_name).bold().fg(theme.primary);
771 });
772 ui.text(&state.description).dim();
773
774 if state.action == ApprovalAction::Pending {
775 let _ = ui.row(|ui| {
776 if ui.button("✓ Approve").clicked {
777 state.action = ApprovalAction::Approved;
778 }
779 if ui.button("✗ Reject").clicked {
780 state.action = ApprovalAction::Rejected;
781 }
782 });
783 } else {
784 let (label, color) = match state.action {
785 ApprovalAction::Approved => ("✓ Approved", theme.success),
786 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
787 ApprovalAction::Pending => unreachable!(),
788 };
789 ui.text(label).fg(color).bold();
790 }
791 });
792
793 Response {
794 changed: state.action != old_action,
795 ..Response::none()
796 }
797 }
798
799 pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
812 if items.is_empty() {
813 return Response::none();
814 }
815
816 let theme = self.theme;
817 let total: usize = items.iter().map(|item| item.tokens).sum();
818
819 let _ = self.container().row(|ui| {
820 ui.text("📎").dim();
821 for item in items {
822 let token_count = format_token_count(item.tokens);
823 let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
824 line.push_str(&item.label);
825 line.push_str(" (");
826 line.push_str(&token_count);
827 line.push(')');
828 ui.text(line).fg(theme.secondary);
829 }
830 ui.spacer();
831 let total_text = format_token_count(total);
832 let mut line = String::with_capacity(2 + total_text.len());
833 line.push_str("Σ ");
834 line.push_str(&total_text);
835 ui.text(line).dim();
836 });
837
838 Response::none()
839 }
840}