1use anyhow::{Context, Result};
4use beyonder_core::{Block, BlockContent, BlockKind, BlockStatus, TuiCell, UnderlineStyle};
5use glyphon::{
6 Attrs, Buffer as GlyphBuffer, Cache, Color as GlyphColor, ColorMode, Cursor as TextCursor,
7 Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds,
8 TextRenderer, Viewport as GlyphViewport,
9};
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12use std::time::Instant;
13use tracing::debug;
14use winit::window::Window;
15
16use crate::block_renderers::{
17 agent_message::render_agent_message, approval::render_approval_block, measure_block_height,
18 render_block_background, shell_block::render_shell_block,
19};
20use crate::pipeline::{RectInstance, RectPipeline};
21use crate::viewport::Viewport;
22
23const INPUT_BAR_HEIGHT: f32 = 120.0;
25const MAX_INPUT_LINES: usize = 4;
27const TAB_BAR_HEIGHT: f32 = 28.0;
29const PADDING: f32 = 4.0;
31const TUI_PAD: f32 = 8.0;
34const GAP: f32 = 2.0;
36
37#[inline]
38fn gc(rgb: [u8; 3]) -> GlyphColor {
39 GlyphColor::rgb(rgb[0], rgb[1], rgb[2])
40}
41
42#[derive(Clone, Debug)]
44pub enum TextSelection {
45 Shell {
47 block_idx: usize,
48 anchor: (usize, usize),
50 cursor: (usize, usize),
52 },
53 Buffer {
55 block_idx: usize,
56 anchor: TextCursor,
57 cursor: TextCursor,
58 },
59}
60
61#[inline]
62fn clamp_char_boundary(s: &str, mut i: usize) -> usize {
63 if i > s.len() {
64 return s.len();
65 }
66 while i < s.len() && !s.is_char_boundary(i) {
67 i += 1;
68 }
69 i
70}
71
72#[inline]
73fn order_rc(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
74 if a <= b {
75 (a, b)
76 } else {
77 (b, a)
78 }
79}
80
81#[inline]
82fn order_cur(a: TextCursor, b: TextCursor) -> (TextCursor, TextCursor) {
83 if (a.line, a.index) <= (b.line, b.index) {
84 (a, b)
85 } else {
86 (b, a)
87 }
88}
89
90pub struct Renderer {
91 pub device: wgpu::Device,
92 pub queue: wgpu::Queue,
93 pub surface: wgpu::Surface<'static>,
94 pub surface_config: wgpu::SurfaceConfiguration,
95 pub rect_pipeline: RectPipeline,
96
97 pub font_system: FontSystem,
99 pub swash_cache: SwashCache,
100 pub glyph_cache: Cache,
101 pub glyph_viewport: GlyphViewport,
102 pub text_atlas: TextAtlas,
103 pub text_renderer: TextRenderer,
104
105 pub viewport: Viewport,
106 pub font_size: f32,
108 pub theme: beyonder_config::Theme,
110 pub scale_factor: f32,
112 measured_cell_size: (f32, f32),
116 measured_metrics_line_h: f32,
120 pub blocks: Vec<Block>,
121
122 pub input_text: String,
124 pub input_cursor: usize,
125 pub input_all_selected: bool,
126 pub input_mode_prefix: String,
127 pub input_preedit: String,
130 pub input_ghost: String,
132 pub input_caret_rect: [f32; 4],
135
136 computed_bar_h: f32,
138 input_scroll_px: f32,
140
141 pub selected_block: Option<usize>,
143 pub selected_sub_output: bool,
145 pub input_running: bool,
147
148 pub tui_active: bool,
149 pub tui_cells: Vec<Vec<TuiCell>>,
150 pub tui_cursor: (usize, usize),
151 pub tui_cursor_shape: u8,
153 pub running_block_idx: Option<usize>,
156
157 cursor_blink_on: bool,
159 cursor_last_toggle: Instant,
160
161 pub spinner_frame: u8,
163 spinner_last_tick: Instant,
164
165 pub context_pills: Vec<String>,
168 pub open_dropdown: Option<(usize, Vec<String>, Option<usize>)>,
170 pub pill_rects: Vec<[f32; 4]>,
172 pub link_rects: Vec<([f32; 4], String)>,
174 pub dropdown_item_rects: Vec<[f32; 4]>,
176
177 pub command_palette: Option<Vec<(String, String)>>,
180 pub cmd_palette_hovered: Option<usize>,
181 pub cmd_palette_rects: Vec<[f32; 4]>,
183
184 pub mode_label: String,
186 pub mode_pill_rect: [f32; 4],
187
188 pub agent_model: String,
190
191 glyph_buf_cache: HashMap<beyonder_core::BlockId, (u64, u32, u32, u32, u64, GlyphBuffer)>,
195 frame_counter: u64,
197
198 block_heights: Vec<f32>,
201 block_fingerprints: Vec<(u8, usize)>,
205 block_y_prefix: Vec<f32>,
208 _blocks_generation: u64,
210 layout_params_key: (u32, u32, Option<usize>),
213
214 header_label_cache: HashMap<beyonder_core::BlockId, (u64, String)>,
217 metadata_line_cache: HashMap<beyonder_core::BlockId, (u64, String)>,
219
220 pub agent_running_tool: HashMap<beyonder_core::AgentId, String>,
223
224 pub user_expanded: HashSet<beyonder_core::BlockId>,
227
228 pub tab_labels: Vec<String>,
231 pub active_tab: usize,
233 pub tab_rects: Vec<[f32; 4]>,
235
236 pub search_match_blocks: Vec<usize>,
238 pub search_current_match: Option<usize>,
240
241 pub text_selection: Option<TextSelection>,
243 pub selecting: bool,
245
246 pub qr_overlays: HashMap<beyonder_core::BlockId, QrBitmap>,
250}
251
252#[derive(Clone, Debug)]
254pub struct QrBitmap {
255 pub width: usize,
256 pub modules: Vec<bool>,
257}
258
259struct TextBufList {
263 entries: Vec<(GlyphBuffer, f32, f32, f32, f32, GlyphColor)>,
264 #[allow(clippy::type_complexity)]
266 keys: Vec<Option<(beyonder_core::BlockId, u64, u32, u32, u32)>>,
267 clip_overrides: Vec<Option<(i32, i32)>>,
271}
272
273impl TextBufList {
274 fn new() -> Self {
275 Self {
276 entries: vec![],
277 keys: vec![],
278 clip_overrides: vec![],
279 }
280 }
281
282 fn push(&mut self, entry: (GlyphBuffer, f32, f32, f32, f32, GlyphColor)) {
284 self.entries.push(entry);
285 self.keys.push(None);
286 self.clip_overrides.push(None);
287 }
288
289 fn push_clipped(
291 &mut self,
292 entry: (GlyphBuffer, f32, f32, f32, f32, GlyphColor),
293 clip: (i32, i32),
294 ) {
295 self.entries.push(entry);
296 self.keys.push(None);
297 self.clip_overrides.push(Some(clip));
298 }
299
300 fn push_cached(
302 &mut self,
303 entry: (GlyphBuffer, f32, f32, f32, f32, GlyphColor),
304 key: (beyonder_core::BlockId, u64, u32, u32, u32),
305 ) {
306 self.entries.push(entry);
307 self.keys.push(Some(key));
308 self.clip_overrides.push(None);
309 }
310
311 fn len(&self) -> usize {
312 self.entries.len()
313 }
314}
315
316impl Renderer {
317 pub async fn new(window: Arc<Window>) -> Result<Self> {
318 let size = window.inner_size();
319
320 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
321 backends: wgpu::Backends::all(),
322 ..Default::default()
323 });
324
325 let surface = instance
326 .create_surface(Arc::clone(&window))
327 .context("Failed to create surface")?;
328
329 let adapter = instance
330 .request_adapter(&wgpu::RequestAdapterOptions {
331 power_preference: wgpu::PowerPreference::HighPerformance,
332 compatible_surface: Some(&surface),
333 force_fallback_adapter: false,
334 })
335 .await
336 .context("No suitable GPU adapter")?;
337
338 let (device, queue) = adapter
339 .request_device(
340 &wgpu::DeviceDescriptor {
341 label: Some("beyonder"),
342 required_features: wgpu::Features::empty(),
343 required_limits: wgpu::Limits::default(),
344 memory_hints: wgpu::MemoryHints::default(),
345 },
346 None,
347 )
348 .await
349 .context("Failed to get GPU device")?;
350
351 let caps = surface.get_capabilities(&adapter);
352 let surface_format = caps
356 .formats
357 .iter()
358 .copied()
359 .find(|f| !f.is_srgb())
360 .unwrap_or(wgpu::TextureFormat::Bgra8Unorm);
361
362 let surface_config = wgpu::SurfaceConfiguration {
363 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
364 format: surface_format,
365 width: size.width.max(1),
366 height: size.height.max(1),
367 present_mode: wgpu::PresentMode::Fifo,
368 alpha_mode: wgpu::CompositeAlphaMode::Auto,
369 view_formats: vec![],
370 desired_maximum_frame_latency: 2,
371 };
372 surface.configure(&device, &surface_config);
373
374 let rect_pipeline = RectPipeline::new(&device, surface_format);
375 rect_pipeline.update_screen_size(&queue, size.width as f32, size.height as f32);
376
377 let mut font_system = FontSystem::new();
379 {
380 let db = font_system.db_mut();
381 db.load_font_file("/System/Library/Fonts/Apple Color Emoji.ttc")
382 .ok();
383 db.load_font_file("/Library/Fonts/Apple Color Emoji.ttc")
384 .ok();
385 db.load_font_file("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf")
386 .ok();
387 }
388 let swash_cache = SwashCache::new();
389 let glyph_cache = Cache::new(&device);
390 let mut glyph_viewport = GlyphViewport::new(&device, &glyph_cache);
391 glyph_viewport.update(
392 &queue,
393 Resolution {
394 width: size.width.max(1),
395 height: size.height.max(1),
396 },
397 );
398 let mut text_atlas = TextAtlas::with_color_mode(
403 &device,
404 &queue,
405 &glyph_cache,
406 surface_format,
407 ColorMode::Web,
408 );
409 let text_renderer = TextRenderer::new(
410 &mut text_atlas,
411 &device,
412 wgpu::MultisampleState::default(),
413 None,
414 );
415
416 let font_size = 16.0; let scale_factor = window.scale_factor() as f32;
418 let input_bar_h = INPUT_BAR_HEIGHT * scale_factor;
419 let viewport = Viewport::new(size.width as f32, size.height as f32 - input_bar_h);
421
422 let (cell_w, cell_h, metrics_line_h) =
423 Self::measure_cell_size_static(&mut font_system, font_size, scale_factor);
424 let measured_cell_size = (cell_w, cell_h);
425 let measured_metrics_line_h = metrics_line_h;
426
427 Ok(Self {
428 device,
429 queue,
430 surface,
431 surface_config,
432 rect_pipeline,
433 font_system,
434 swash_cache,
435 glyph_cache,
436 glyph_viewport,
437 text_atlas,
438 text_renderer,
439 viewport,
440 font_size,
441 theme: beyonder_config::Theme::default(),
442 scale_factor,
443 measured_cell_size,
444 measured_metrics_line_h,
445 blocks: vec![],
446 input_text: String::new(),
447 input_cursor: 0,
448 input_all_selected: false,
449 input_mode_prefix: "> ".to_string(),
450 input_preedit: String::new(),
451 input_ghost: String::new(),
452 input_caret_rect: [0.0; 4],
453 computed_bar_h: INPUT_BAR_HEIGHT * scale_factor,
454 input_scroll_px: 0.0,
455
456 selected_block: None,
457 selected_sub_output: false,
458 input_running: false,
459 tui_active: false,
460 tui_cells: vec![],
461 tui_cursor: (0, 0),
462 tui_cursor_shape: 0,
463 running_block_idx: None,
464 cursor_blink_on: true,
465 cursor_last_toggle: Instant::now(),
466 spinner_frame: 0,
467 spinner_last_tick: Instant::now(),
468 context_pills: vec![],
469 open_dropdown: None,
470 pill_rects: vec![],
471 link_rects: vec![],
472 dropdown_item_rects: vec![],
473 command_palette: None,
474 cmd_palette_hovered: None,
475 cmd_palette_rects: vec![],
476 mode_label: "auto".to_string(),
477 mode_pill_rect: [0.0; 4],
478 agent_model: String::new(),
479 glyph_buf_cache: HashMap::new(),
480 frame_counter: 0,
481 block_heights: vec![],
482 block_fingerprints: vec![],
483 block_y_prefix: vec![0.0],
484 _blocks_generation: 0,
485 layout_params_key: (0, 0, None),
486 header_label_cache: HashMap::new(),
487 metadata_line_cache: HashMap::new(),
488 agent_running_tool: HashMap::new(),
489 user_expanded: HashSet::new(),
490 tab_labels: vec![],
491 active_tab: 0,
492 tab_rects: vec![],
493 search_match_blocks: vec![],
494 search_current_match: None,
495 text_selection: None,
496 selecting: false,
497 qr_overlays: HashMap::new(),
498 })
499 }
500
501 fn format_shell_meta(cwd: &std::path::Path, duration_ms: Option<u64>) -> String {
503 static HOME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
504 let home = HOME.get_or_init(|| std::env::var("HOME").unwrap_or_default());
505 let cwd_str = cwd.to_str().unwrap_or("~");
506 let dir_display = if !home.is_empty() && cwd_str.starts_with(home.as_str()) {
507 format!("~{}", &cwd_str[home.len()..])
508 } else {
509 cwd_str.to_string()
510 };
511 match duration_ms.map(format_duration) {
512 Some(d) => format!("{} {}", dir_display, d),
513 None => dir_display,
514 }
515 }
516
517 fn cached_header_label(&mut self, block: &Block) -> String {
519 let gen = block.updated_at.timestamp_millis() as u64;
520 if let Some((cached_gen, cached)) = self.header_label_cache.get(&block.id) {
521 if *cached_gen == gen {
522 return cached.clone();
523 }
524 }
525 let label = block_header_label(block);
526 self.header_label_cache
527 .insert(block.id.clone(), (gen, label.clone()));
528 label
529 }
530
531 pub fn resize(&mut self, width: u32, height: u32) {
532 if width == 0 || height == 0 {
533 return;
534 }
535 self.surface_config.width = width;
536 self.surface_config.height = height;
537 self.surface.configure(&self.device, &self.surface_config);
538 let tab_h = self.tab_bar_height_phys();
539 self.viewport
540 .resize(width as f32, height as f32 - self.computed_bar_h - tab_h);
541 self.viewport.top_offset = tab_h;
542 self.rect_pipeline
543 .update_screen_size(&self.queue, width as f32, height as f32);
544 self.glyph_viewport
545 .update(&self.queue, Resolution { width, height });
546 }
547
548 pub fn tab_bar_height_phys(&self) -> f32 {
550 if self.tab_labels.len() >= 2 && !self.tui_active {
551 TAB_BAR_HEIGHT * self.scale_factor
552 } else {
553 0.0
554 }
555 }
556
557 pub fn tab_hit(&self, px: f32, py: f32) -> Option<usize> {
559 for (i, rect) in self.tab_rects.iter().enumerate() {
560 let [x, y, w, h] = *rect;
561 if px >= x && px < x + w && py >= y && py < y + h {
562 return Some(i);
563 }
564 }
565 None
566 }
567
568 pub fn set_scale_factor(&mut self, scale_factor: f64) {
569 self.scale_factor = scale_factor as f32;
570 let (cell_w, cell_h, metrics_line_h) = Self::measure_cell_size_static(
571 &mut self.font_system,
572 self.font_size,
573 self.scale_factor,
574 );
575 self.measured_cell_size = (cell_w, cell_h);
576 self.measured_metrics_line_h = metrics_line_h;
577 }
578
579 fn measure_cell_size_static(
587 font_system: &mut FontSystem,
588 font_size: f32,
589 scale_factor: f32,
590 ) -> (f32, f32, f32) {
591 let phys = font_size * scale_factor;
592 let metrics = Metrics::new(phys, phys * 2.0);
593 let mut buf = GlyphBuffer::new(font_system, metrics);
594 buf.set_size(font_system, None, None);
595 buf.set_text(
596 font_system,
597 "Mg",
598 Attrs::new().family(Family::Name("JetBrainsMono Nerd Font")),
599 Shaping::Basic,
600 );
601 buf.shape_until_scroll(font_system, false);
602
603 let mut cell_w = (phys * 0.6).round();
604 let mut metrics_line_h = phys * 1.2; let mut cell_h = metrics_line_h.floor();
606
607 if let Some(buf_line) = buf.lines.first() {
608 if let Some(layout_lines) = buf_line.layout_opt() {
609 if let Some(ll) = layout_lines.first() {
610 metrics_line_h = ll.max_ascent + ll.max_descent;
611 cell_h = metrics_line_h.floor();
615 }
616 }
617 }
618 if let Some(run) = buf.layout_runs().next() {
619 if let Some(last) = run.glyphs.last() {
620 let total_w = last.x + last.w;
621 let n = run.glyphs.len() as f32;
622 cell_w = (total_w / n).round();
623 }
624 }
625
626 (cell_w, cell_h, metrics_line_h)
627 }
628
629 pub fn block_hit_at(&self, screen_y: f32) -> Option<(usize, bool)> {
633 let sc = self.scale_factor;
634 let phys_font = self.font_size * sc;
635 let inner_gap = phys_font * 0.4;
636 for i in 0..self.blocks.len() {
637 let y = self.block_y_prefix.get(i).copied().unwrap_or(0.0);
638 let h = self.block_heights.get(i).copied().unwrap_or(0.0);
639 let sy = self.viewport.content_to_screen_y(y);
640 if screen_y >= sy && screen_y < sy + h {
641 let block = &self.blocks[i];
642 let shell_cmd_bar_h = phys_font * 2.8;
643 let is_output = matches!(block.content, BlockContent::ShellCommand { .. })
644 && screen_y >= sy + shell_cmd_bar_h + inner_gap;
645 return Some((i, is_output));
646 }
647 }
648 None
649 }
650
651 pub fn block_index_at(&self, screen_y: f32) -> Option<usize> {
653 self.block_hit_at(screen_y).map(|(i, _)| i)
654 }
655
656 fn shell_output_geom(&self, block_idx: usize) -> Option<(f32, f32, f32, f32)> {
661 let sc = self.scale_factor;
662 let padding = PADDING * sc;
663 let phys_font = self.font_size * sc;
664 let inner_gap = phys_font * 0.4;
665 let cmd_bar_h = phys_font * 2.8;
666 let (cell_w, cell_h) = self.terminal_cell_size();
667 if cell_w <= 0.0 || cell_h <= 0.0 {
668 return None;
669 }
670 let output_pad_x = 4.0 * sc;
671 let content_x = padding + output_pad_x;
672 let y = self.block_top_y(block_idx)?;
673 let sy = self.viewport.content_to_screen_y(y);
674 let base_y = sy + cmd_bar_h + inner_gap + 2.0 * sc;
675 Some((content_x, base_y, cell_w, cell_h))
676 }
677
678 fn agent_buffer_geom(&self, block_idx: usize) -> Option<(f32, f32, f32)> {
681 let sc = self.scale_factor;
682 let padding = PADDING * sc;
683 let content_w = self.viewport.width - padding * 2.0;
684 let content_pad = 8.0 * sc;
685 let x_content = padding + content_pad;
686 let buf_w = (content_w - content_pad * 2.0).max(1.0);
687 let y = self.block_top_y(block_idx)?;
688 let sy = self.viewport.content_to_screen_y(y);
689 let text_y = sy + 4.0 * sc;
690 Some((x_content, text_y, buf_w))
691 }
692
693 fn shell_cell_at(&self, block_idx: usize, phys_x: f32, phys_y: f32) -> Option<(usize, usize)> {
694 let block = self.blocks.get(block_idx)?;
695 let BlockContent::ShellCommand { output, .. } = &block.content else {
696 return None;
697 };
698 let (content_x, base_y, cell_w, cell_h) = self.shell_output_geom(block_idx)?;
699 let local_y = (phys_y - base_y).max(0.0);
700 let row_count = output.rows.len();
701 if row_count == 0 {
702 return None;
703 }
704 let row = ((local_y / cell_h).floor() as usize).min(row_count - 1);
705 let row_cells = output.rows[row].cells.len();
706 let local_x = (phys_x - content_x).max(0.0);
707 let col = ((local_x / cell_w).floor() as usize).min(row_cells);
708 Some((row, col))
709 }
710
711 fn is_buffer_block(block: &Block) -> bool {
715 matches!(
716 block.content,
717 BlockContent::AgentMessage { .. } | BlockContent::Text { .. }
718 )
719 }
720
721 fn buffer_cursor_at(
722 &mut self,
723 block_idx: usize,
724 phys_x: f32,
725 phys_y: f32,
726 ) -> Option<TextCursor> {
727 let block = self.blocks.get(block_idx)?.clone();
728 if !Self::is_buffer_block(&block) {
729 return None;
730 }
731 let content_text = block_content_text(&block);
732 if content_text.is_empty() {
733 return None;
734 }
735 let is_markdown = matches!(block.content, BlockContent::AgentMessage { .. });
736 let sc = self.scale_factor;
737 let phys_font = self.font_size * sc;
738 let line_h = phys_font * 1.4;
739 let (x_content, text_y, buf_w) = self.agent_buffer_geom(block_idx)?;
740 let content_len = content_text.len() as u64;
741 let bw_bits = buf_w.to_bits();
742 let pf_bits = phys_font.to_bits();
743 let vh_bits = self.viewport.height.to_bits();
744 let fc = self.frame_counter;
745 let cached_matches = self
746 .glyph_buf_cache
747 .get(&block.id)
748 .map(|(l, b, p, v, _, _)| {
749 *l == content_len && *b == bw_bits && *p == pf_bits && *v == vh_bits
750 })
751 .unwrap_or(false);
752 if !cached_matches {
753 let buf = if is_markdown {
754 self.make_markdown_buffer(&content_text, buf_w, phys_font).0
755 } else {
756 self.make_buffer(&content_text, buf_w, phys_font, gc(self.theme.text))
757 };
758 self.glyph_buf_cache.insert(
759 block.id.clone(),
760 (content_len, bw_bits, pf_bits, vh_bits, fc, buf),
761 );
762 } else if let Some(entry) = self.glyph_buf_cache.get_mut(&block.id) {
763 entry.4 = fc; }
765 let (_, _, _, _, _, buf) = self.glyph_buf_cache.get(&block.id)?;
766 let skipped = if is_markdown {
767 let max_vis = ((self.viewport.height / line_h).ceil() as usize + 30).max(50);
768 content_text.lines().count().saturating_sub(max_vis)
769 } else {
770 0
771 };
772 let adjusted_text_y = text_y + skipped as f32 * line_h;
773 let local_x = (phys_x - x_content).max(0.0);
774 let local_y = (phys_y - adjusted_text_y).max(0.0);
775 buf.hit(local_x, local_y)
776 }
777
778 pub fn begin_text_selection(&mut self, phys_x: f32, phys_y: f32) -> bool {
780 self.text_selection = None;
781 self.selecting = false;
782 let Some((idx, _)) = self.block_hit_at(phys_y) else {
783 return false;
784 };
785 let Some(block) = self.blocks.get(idx) else {
786 return false;
787 };
788 match &block.content {
789 BlockContent::ShellCommand { .. } => {
790 let Some((_, base_y, _, _)) = self.shell_output_geom(idx) else {
792 return false;
793 };
794 if phys_y < base_y {
795 return false;
796 }
797 if let Some((row, col)) = self.shell_cell_at(idx, phys_x, phys_y) {
798 self.text_selection = Some(TextSelection::Shell {
799 block_idx: idx,
800 anchor: (row, col),
801 cursor: (row, col),
802 });
803 self.selecting = true;
804 return true;
805 }
806 }
807 BlockContent::AgentMessage { .. } | BlockContent::Text { .. } => {
808 if let Some(cur) = self.buffer_cursor_at(idx, phys_x, phys_y) {
809 self.text_selection = Some(TextSelection::Buffer {
810 block_idx: idx,
811 anchor: cur,
812 cursor: cur,
813 });
814 self.selecting = true;
815 return true;
816 }
817 }
818 _ => {}
819 }
820 false
821 }
822
823 pub fn update_text_selection(&mut self, phys_x: f32, phys_y: f32) {
825 if !self.selecting {
826 return;
827 }
828 let Some(sel) = self.text_selection.clone() else {
829 return;
830 };
831 match sel {
832 TextSelection::Shell {
833 block_idx, anchor, ..
834 } => {
835 if let Some(rc) = self.shell_cell_at(block_idx, phys_x, phys_y) {
836 self.text_selection = Some(TextSelection::Shell {
837 block_idx,
838 anchor,
839 cursor: rc,
840 });
841 }
842 }
843 TextSelection::Buffer {
844 block_idx, anchor, ..
845 } => {
846 if let Some(cur) = self.buffer_cursor_at(block_idx, phys_x, phys_y) {
847 self.text_selection = Some(TextSelection::Buffer {
848 block_idx,
849 anchor,
850 cursor: cur,
851 });
852 }
853 }
854 }
855 }
856
857 pub fn end_text_selection(&mut self) {
859 self.selecting = false;
860 }
861
862 pub fn clear_text_selection(&mut self) {
863 self.text_selection = None;
864 self.selecting = false;
865 }
866
867 pub fn set_qr_block(&mut self, block_id: beyonder_core::BlockId, qr: QrBitmap) {
871 self.qr_overlays.insert(block_id, qr);
872 }
873
874 fn qr_mod_px(&self, qr: &QrBitmap) -> f32 {
877 let sc = self.scale_factor;
878 let side_modules = (qr.width + 8) as f32; let target = 250.0 * sc;
881
882 (target / side_modules).floor().max(2.0)
883 }
884
885 fn qr_overlay_height(&self, qr: &QrBitmap, _content_w: f32) -> f32 {
887 let sc = self.scale_factor;
888 let content_pad = 8.0 * sc;
889 let mod_px = self.qr_mod_px(qr);
890 let side_modules = (qr.width + 8) as f32;
891 mod_px * side_modules + content_pad * 2.0
892 }
893
894 fn paint_qr_block(
895 &self,
896 qr: &QrBitmap,
897 x: f32,
898 sy: f32,
899 _content_w: f32,
900 rects: &mut Vec<RectInstance>,
901 ) {
902 if qr.width == 0 || qr.modules.is_empty() {
903 return;
904 }
905 let sc = self.scale_factor;
906 let content_pad = 8.0 * sc;
907 let mod_px = self.qr_mod_px(qr);
908 let side_modules = (qr.width + 8) as f32;
909 let qr_side = mod_px * side_modules;
910 let qr_x = (x + content_pad).floor();
912 let qr_y = (sy + content_pad).floor();
913 let white = [1.0, 1.0, 1.0, 1.0];
914 let black = [0.0, 0.0, 0.0, 1.0];
915 rects.push(RectInstance::filled(qr_x, qr_y, qr_side, qr_side, white));
916 let quiet = 4.0 * mod_px;
917 for (i, &dark) in qr.modules.iter().enumerate() {
918 if !dark {
919 continue;
920 }
921 let mx = (i % qr.width) as f32;
922 let my = (i / qr.width) as f32;
923 let px = qr_x + quiet + mx * mod_px;
924 let py = qr_y + quiet + my * mod_px;
925 rects.push(RectInstance::filled(
926 px.floor(),
927 py.floor(),
928 mod_px.ceil(),
929 mod_px.ceil(),
930 black,
931 ));
932 }
933 }
934
935 pub fn has_text_selection(&self) -> bool {
937 match &self.text_selection {
938 Some(TextSelection::Shell { anchor, cursor, .. }) => anchor != cursor,
939 Some(TextSelection::Buffer { anchor, cursor, .. }) => {
940 (anchor.line, anchor.index) != (cursor.line, cursor.index)
941 }
942 None => false,
943 }
944 }
945
946 pub fn selected_text(&self) -> Option<String> {
948 match &self.text_selection {
949 Some(TextSelection::Shell {
950 block_idx,
951 anchor,
952 cursor,
953 }) => {
954 let block = self.blocks.get(*block_idx)?;
955 let BlockContent::ShellCommand { output, .. } = &block.content else {
956 return None;
957 };
958 let (s, e) = order_rc(*anchor, *cursor);
959 if s == e {
960 return None;
961 }
962 let mut out = String::new();
963 let row_max = output.rows.len().saturating_sub(1);
964 let s_row = s.0.min(row_max);
965 let e_row = e.0.min(row_max);
966 for row_idx in s_row..=e_row {
967 let row = &output.rows[row_idx];
968 let start_col = if row_idx == s_row { s.1 } else { 0 };
969 let end_col = if row_idx == e_row {
970 e.1.min(row.cells.len())
971 } else {
972 row.cells.len()
973 };
974 if end_col > start_col {
975 for cell in &row.cells[start_col..end_col] {
976 out.push_str(cell.grapheme.as_str());
977 }
978 }
979 if row_idx != e_row {
980 out.push('\n');
981 }
982 }
983 let trimmed = out.trim_end_matches([' ', '\t']).to_string();
984 if trimmed.is_empty() {
985 None
986 } else {
987 Some(trimmed)
988 }
989 }
990 Some(TextSelection::Buffer {
991 block_idx,
992 anchor,
993 cursor,
994 }) => {
995 let block = self.blocks.get(*block_idx)?;
996 let (s, e) = order_cur(*anchor, *cursor);
997 if (s.line, s.index) == (e.line, e.index) {
998 return None;
999 }
1000 let (_, _, _, _, _, buf) = self.glyph_buf_cache.get(&block.id)?;
1001 let mut out = String::new();
1002 let last = buf.lines.len().saturating_sub(1);
1003 let s_line = s.line.min(last);
1004 let e_line = e.line.min(last);
1005 for line_i in s_line..=e_line {
1006 let text = buf.lines[line_i].text();
1007 let mut start = if line_i == s_line { s.index } else { 0 };
1008 let mut end = if line_i == e_line {
1009 e.index
1010 } else {
1011 text.len()
1012 };
1013 start = clamp_char_boundary(text, start.min(text.len()));
1014 end = clamp_char_boundary(text, end.min(text.len()));
1015 if end > start {
1016 out.push_str(&text[start..end]);
1017 }
1018 if line_i != e_line {
1019 out.push('\n');
1020 }
1021 }
1022 if out.is_empty() {
1023 None
1024 } else {
1025 Some(out)
1026 }
1027 }
1028 None => None,
1029 }
1030 }
1031
1032 pub fn mode_pill_hit(&self, px: f32, py: f32) -> bool {
1034 let [x, y, w, h] = self.mode_pill_rect;
1035 w > 0.0 && px >= x && px < x + w && py >= y && py < y + h
1036 }
1037
1038 pub fn pill_hit(&self, px: f32, py: f32) -> Option<usize> {
1040 for (i, rect) in self.pill_rects.iter().enumerate() {
1041 let [x, y, w, h] = *rect;
1042 if px >= x && px < x + w && py >= y && py < y + h {
1043 return Some(i);
1044 }
1045 }
1046 None
1047 }
1048
1049 pub fn cmd_palette_hit(&self, px: f32, py: f32) -> Option<usize> {
1051 for (i, rect) in self.cmd_palette_rects.iter().enumerate() {
1052 let [x, y, w, h] = *rect;
1053 if px >= x && px < x + w && py >= y && py < y + h {
1054 return Some(i);
1055 }
1056 }
1057 None
1058 }
1059
1060 pub fn dropdown_hit(&self, px: f32, py: f32) -> Option<usize> {
1062 for (i, rect) in self.dropdown_item_rects.iter().enumerate() {
1063 let [x, y, w, h] = *rect;
1064 if px >= x && px < x + w && py >= y && py < y + h {
1065 return Some(i);
1066 }
1067 }
1068 None
1069 }
1070
1071 pub fn dropdown_hover_at(&self, px: f32, py: f32) -> Option<usize> {
1073 self.dropdown_hit(px, py)
1074 }
1075
1076 pub fn scroll(&mut self, delta: f32) {
1077 self.viewport.scroll(delta);
1078 }
1079
1080 pub fn scroll_to_bottom(&mut self) {
1081 self.viewport.scroll_to_bottom();
1082 }
1083
1084 pub fn terminal_cell_size(&self) -> (f32, f32) {
1088 self.measured_cell_size
1089 }
1090
1091 pub fn bar_height_phys(&self) -> f32 {
1093 self.computed_bar_h
1094 }
1095
1096 pub fn surface_size(&self) -> (f32, f32) {
1098 (
1099 self.surface_config.width as f32,
1100 self.surface_config.height as f32,
1101 )
1102 }
1103
1104 pub fn scroll_input(&mut self, delta: f32) {
1107 let sc = self.scale_factor;
1108 let phys_font = self.font_size * sc;
1109 let win_w = self.surface_config.width as f32;
1110 let h_pad = 14.0 * sc;
1111 let text_w = (win_w - h_pad * 2.0).max(1.0);
1112 let line_h = phys_font * 1.4;
1113 let (total_lines, _) = self.measure_input_lines(text_w, phys_font);
1114 let visible_lines = total_lines.min(MAX_INPUT_LINES);
1115 let max_scroll = ((total_lines as f32 - visible_lines as f32) * line_h).max(0.0);
1116 self.input_scroll_px = (self.input_scroll_px + delta).clamp(0.0, max_scroll);
1117 }
1118
1119 pub fn snap_input_scroll_to_cursor(&mut self) {
1121 let sc = self.scale_factor;
1122 let phys_font = self.font_size * sc;
1123 let win_w = self.surface_config.width as f32;
1124 let h_pad = 14.0 * sc;
1125 let text_w = (win_w - h_pad * 2.0).max(1.0);
1126 let line_h = phys_font * 1.4;
1127 let (total_lines, cursor_line) = self.measure_input_lines(text_w, phys_font);
1128 let visible_lines = total_lines.min(MAX_INPUT_LINES);
1129 let viewport_h = visible_lines as f32 * line_h;
1130 let cursor_top = cursor_line as f32 * line_h;
1131 let cursor_bot = cursor_top + line_h;
1132 if cursor_top < self.input_scroll_px {
1133 self.input_scroll_px = cursor_top;
1134 }
1135 if cursor_bot > self.input_scroll_px + viewport_h {
1136 self.input_scroll_px = cursor_bot - viewport_h;
1137 }
1138 let max_scroll = ((total_lines as f32 - visible_lines as f32) * line_h).max(0.0);
1139 self.input_scroll_px = self.input_scroll_px.clamp(0.0, max_scroll);
1140 }
1141
1142 fn measure_input_lines(&mut self, text_w: f32, phys_font: f32) -> (usize, usize) {
1145 if self.input_text.is_empty() || self.input_running {
1146 return (1, 0);
1147 }
1148 let cursor = self.input_cursor.min(self.input_text.len());
1149 let before = &self.input_text[..cursor];
1150 let after = &self.input_text[cursor..];
1151 let text = if self.input_all_selected {
1152 format!("{}█{}", self.input_mode_prefix, self.input_text)
1153 } else {
1154 format!("{}{}▌{}", self.input_mode_prefix, before, after)
1155 };
1156
1157 let prefix_len = self.input_mode_prefix.len();
1159 let caret_byte_end = if self.input_all_selected {
1160 text.len()
1161 } else {
1162 prefix_len + cursor + "▌".len()
1163 };
1164
1165 let col = gc(self.theme.text);
1166 let buf = self.make_buffer(&text, text_w, phys_font, col);
1167
1168 let mut total_lines = 0usize;
1169 let mut cursor_line = 0usize;
1170
1171 for run in buf.layout_runs() {
1172 let run_end = run.glyphs.last().map(|g| g.end).unwrap_or(0);
1173 if run_end < caret_byte_end {
1175 cursor_line = total_lines + 1;
1176 }
1177 total_lines += 1;
1178 }
1179 let cursor_line = cursor_line.min(total_lines.saturating_sub(1));
1181
1182 (total_lines.max(1), cursor_line)
1183 }
1184
1185 fn compute_bar_state(&mut self) {
1188 let sc = self.scale_factor;
1189 let phys_font = self.font_size * sc;
1190 let win_w = self.surface_config.width as f32;
1191 let h_pad = 14.0 * sc;
1192 let text_w = (win_w - h_pad * 2.0).max(1.0);
1193 let line_h = phys_font * 1.4;
1194
1195 let (total_lines, cursor_line) = self.measure_input_lines(text_w, phys_font);
1196 let visible_lines = total_lines.min(MAX_INPUT_LINES);
1197
1198 let line_h_logical = self.font_size * 1.4;
1201 let extra_lines = (visible_lines as f32 - 1.0).max(0.0);
1202 let bar_h_logical = INPUT_BAR_HEIGHT + extra_lines * line_h_logical;
1203 self.computed_bar_h = bar_h_logical * sc;
1204
1205 let max_scroll = ((total_lines as f32 - visible_lines as f32) * line_h).max(0.0);
1207 self.input_scroll_px = self.input_scroll_px.clamp(0.0, max_scroll);
1208
1209 if total_lines > 1 {
1210 tracing::info!(
1211 total_lines,
1212 cursor_line,
1213 visible_lines,
1214 input_scroll_px = self.input_scroll_px,
1215 "input bar state"
1216 );
1217 }
1218 }
1219
1220 pub fn cell_at_phys(&self, px: f32, py: f32) -> Option<(u32, u32)> {
1223 if !self.tui_active {
1224 return None;
1225 }
1226 let (cell_w, cell_h) = self.terminal_cell_size();
1227 if cell_w <= 0.0 || cell_h <= 0.0 {
1228 return None;
1229 }
1230 let pad = TUI_PAD * self.scale_factor;
1231 let lx = px - pad;
1232 let ly = py - pad;
1233 if lx < 0.0 || ly < 0.0 {
1234 return None;
1235 }
1236 let (cols, rows) = self.tui_grid_size();
1237 let c = (lx / cell_w).floor() as i32;
1238 let r = (ly / cell_h).floor() as i32;
1239 if c < 0 || r < 0 || c >= cols as i32 || r >= rows as i32 {
1240 return None;
1241 }
1242 Some((c as u32 + 1, r as u32 + 1))
1243 }
1244
1245 pub fn tui_grid_size(&self) -> (u16, u16) {
1247 let (cell_w, cell_h) = self.terminal_cell_size();
1248 let pad = TUI_PAD * self.scale_factor;
1249 let full_w = (self.surface_config.width as f32 - pad * 2.0).max(cell_w);
1250 let full_h = (self.surface_config.height as f32 - pad * 2.0).max(cell_h);
1251 let cols = (full_w / cell_w).floor().max(40.0) as u16;
1252 let rows = (full_h / cell_h).floor().max(10.0) as u16;
1253 (cols, rows)
1254 }
1255
1256 pub fn terminal_grid_size(&self) -> (u16, u16) {
1261 let (cell_w, cell_h) = self.terminal_cell_size();
1262 let sc = self.scale_factor;
1263 let content_w = (self.viewport.width - PADDING * sc * 2.0).max(cell_w);
1264 let content_h = self.viewport.height.max(cell_h);
1265 let cols = (content_w / cell_w).floor().max(40.0) as u16;
1266 let rows = (content_h / cell_h).floor().max(10.0) as u16;
1267 (cols, rows)
1268 }
1269
1270 pub fn render(&mut self) -> Result<()> {
1271 self.frame_counter += 1;
1272
1273 let now = Instant::now();
1275 if now.duration_since(self.cursor_last_toggle).as_millis() >= 530 {
1276 self.cursor_blink_on = !self.cursor_blink_on;
1277 self.cursor_last_toggle = now;
1278 }
1279 if now.duration_since(self.spinner_last_tick).as_millis() >= 80 {
1281 self.spinner_frame = (self.spinner_frame + 1) % 10;
1282 self.spinner_last_tick = now;
1283 }
1284
1285 let old_bar_h = self.computed_bar_h;
1287 let old_tab_h = self.viewport.top_offset;
1288 self.compute_bar_state();
1289 let tab_h = self.tab_bar_height_phys();
1290 if (self.computed_bar_h - old_bar_h).abs() > 0.5 || (tab_h - old_tab_h).abs() > 0.5 {
1291 let w = self.surface_config.width as f32;
1292 let h = self.surface_config.height as f32;
1293 self.viewport.resize(w, h - self.computed_bar_h - tab_h);
1294 self.viewport.top_offset = tab_h;
1295 }
1296
1297 let output = match self.surface.get_current_texture() {
1298 Ok(t) => t,
1299 Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
1300 self.surface.configure(&self.device, &self.surface_config);
1301 return Ok(());
1302 }
1303 Err(e) => return Err(e.into()),
1304 };
1305
1306 let view = output
1307 .texture
1308 .create_view(&wgpu::TextureViewDescriptor::default());
1309 let mut encoder = self
1310 .device
1311 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1312 label: Some("beyonder_frame"),
1313 });
1314
1315 let mut rects = if !self.tui_active {
1317 let (r, total_h) = self.layout_blocks();
1318 self.viewport.total_content_height = total_h;
1319 if self.running_block_idx.is_some() && self.viewport.pinned_to_bottom {
1320 self.viewport.scroll_to_bottom();
1321 }
1322 r
1323 } else {
1324 let mut r = vec![];
1325 self.layout_tui(&mut r);
1326 r
1327 };
1328 let bar_hidden = self.tui_active;
1332 if !bar_hidden {
1333 self.append_bar_rects(&mut rects);
1334 }
1335 self.append_tab_bar_rects(&mut rects);
1336 self.rect_pipeline.upload_instances(&self.queue, &rects);
1337
1338 debug!(blocks = self.blocks.len(), "render: building text buffers");
1342 let (mut buf_list, block_entry_count) = if self.tui_active {
1343 let texts = self.build_tui_text_buffers();
1344 let count = texts.len();
1345 (texts, count)
1346 } else {
1347 self.build_text_buffers()
1348 };
1349 if !bar_hidden {
1350 let bar_texts = self.build_bar_text_buffers();
1351 buf_list.entries.extend(bar_texts.entries);
1352 buf_list.keys.extend(bar_texts.keys);
1353 buf_list.clip_overrides.extend(bar_texts.clip_overrides);
1354 }
1355 self.build_tab_bar_text_buffers(&mut buf_list);
1356 debug!(
1357 entries = buf_list.entries.len(),
1358 "render: text buffers built"
1359 );
1360 let win_h = self.surface_config.height as f32;
1361 let text_clip_bottom = if bar_hidden {
1364 win_h
1365 } else {
1366 win_h - self.computed_bar_h
1367 };
1368 let block_clip_top_min = self.tab_bar_height_phys() as i32;
1370 let text_areas: Vec<TextArea> = buf_list
1371 .entries
1372 .iter()
1373 .enumerate()
1374 .map(|(i, (buf, x, y, w, h, color))| {
1375 let (clip_top, clip_bottom) = if let Some((ct, cb)) = buf_list.clip_overrides[i] {
1379 (ct, cb)
1380 } else if i < block_entry_count {
1381 (
1382 (*y as i32).max(block_clip_top_min),
1383 ((*y + *h) as i32).min(text_clip_bottom as i32),
1384 )
1385 } else {
1386 ((*y as i32).max(0), (*y + *h) as i32)
1387 };
1388 TextArea {
1389 buffer: buf,
1390 left: *x,
1391 top: *y,
1392 scale: 1.0,
1393 bounds: TextBounds {
1394 left: (*x as i32).max(0),
1395 top: clip_top,
1396 right: (*x + *w) as i32,
1397 bottom: clip_bottom,
1398 },
1399 default_color: *color,
1400 custom_glyphs: &[],
1401 }
1402 })
1403 .collect();
1404
1405 debug!("render: calling text_renderer.prepare");
1406 if let Err(e) = self.text_renderer.prepare(
1407 &self.device,
1408 &self.queue,
1409 &mut self.font_system,
1410 &mut self.text_atlas,
1411 &self.glyph_viewport,
1412 text_areas,
1413 &mut self.swash_cache,
1414 ) {
1415 tracing::warn!("glyph atlas prepare failed: {e:?}");
1416 }
1417 debug!("render: text_renderer.prepare done");
1418
1419 let fc = self.frame_counter;
1422 for ((buf, ..), key) in buf_list.entries.into_iter().zip(buf_list.keys.into_iter()) {
1423 if let Some((id, len, bw, pf, vh)) = key {
1424 self.glyph_buf_cache.insert(id, (len, bw, pf, vh, fc, buf));
1425 }
1426 }
1427
1428 {
1429 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1430 label: Some("beyonder_main"),
1431 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1432 view: &view,
1433 resolve_target: None,
1434 ops: wgpu::Operations {
1435 load: wgpu::LoadOp::Clear(wgpu::Color {
1436 r: self.theme.bg[0] as f64,
1437 g: self.theme.bg[1] as f64,
1438 b: self.theme.bg[2] as f64,
1439 a: self.theme.bg[3] as f64,
1440 }),
1441 store: wgpu::StoreOp::Store,
1442 },
1443 })],
1444 depth_stencil_attachment: None,
1445 timestamp_writes: None,
1446 occlusion_query_set: None,
1447 });
1448
1449 self.rect_pipeline.draw(&mut pass, rects.len() as u32);
1450 if let Err(e) =
1451 self.text_renderer
1452 .render(&self.text_atlas, &self.glyph_viewport, &mut pass)
1453 {
1454 tracing::warn!("glyph atlas render failed: {e:?}");
1455 }
1456 }
1457
1458 debug!("render: submitting GPU commands");
1459 self.queue.submit([encoder.finish()]);
1460 output.present();
1461 debug!("render: frame presented");
1462
1463 self.text_atlas.trim();
1465
1466 const EVICT_AGE: u64 = 120;
1469 const MAX_CACHE: usize = 256;
1470 let fc = self.frame_counter;
1471 if self.glyph_buf_cache.len() > MAX_CACHE || fc.is_multiple_of(60) {
1472 self.glyph_buf_cache
1473 .retain(|_, (_, _, _, _, last, _)| fc.saturating_sub(*last) < EVICT_AGE);
1474 }
1475
1476 Ok(())
1477 }
1478
1479 fn live_block_height(&self, phys_font: f32) -> f32 {
1486 let last_content = self
1487 .tui_cells
1488 .iter()
1489 .rposition(|row| {
1490 row.iter().any(|c| {
1491 let fc = c.first_char();
1492 fc != ' ' && fc != '\0'
1493 })
1494 })
1495 .map(|i| i + 1)
1496 .unwrap_or(1);
1497 let (_, cell_h) = self.terminal_cell_size();
1498 let cmd_bar_h = phys_font * 2.8;
1499 let inner_gap = phys_font * 0.4;
1500 cmd_bar_h + inner_gap + last_content as f32 * cell_h + cell_h * 0.5
1501 }
1502
1503 fn is_collapsed(&self, block: &Block) -> bool {
1506 match &block.content {
1507 BlockContent::ToolCall {
1508 collapsed_default,
1509 output,
1510 ..
1511 } => output.is_some() && *collapsed_default && !self.user_expanded.contains(&block.id),
1512 _ => false,
1513 }
1514 }
1515
1516 pub fn set_theme(&mut self, theme: beyonder_config::Theme) {
1518 self.theme = theme;
1519 self.glyph_buf_cache.clear();
1520 }
1521
1522 pub fn toggle_collapsed(&mut self, block_id: &beyonder_core::BlockId) {
1524 if self.user_expanded.contains(block_id) {
1525 self.user_expanded.remove(block_id);
1526 } else {
1527 self.user_expanded.insert(block_id.clone());
1528 }
1529 self.layout_params_key = (0, 0, None);
1531 self.glyph_buf_cache.remove(block_id);
1532 }
1533
1534 pub fn block_top_y(&self, idx: usize) -> Option<f32> {
1537 if idx >= self.blocks.len() {
1538 return None;
1539 }
1540 self.block_y_prefix.get(idx).copied()
1541 }
1542
1543 fn block_height(&self, idx: usize, block: &Block, content_w: f32, phys_font: f32) -> f32 {
1545 if let Some(qr) = self.qr_overlays.get(&block.id) {
1546 return self.qr_overlay_height(qr, content_w);
1547 }
1548 if self.is_collapsed(block) {
1549 phys_font * 1.8 } else if self.running_block_idx == Some(idx) && !self.tui_cells.is_empty() {
1551 self.live_block_height(phys_font)
1552 } else {
1553 measure_block_height(block, content_w, phys_font)
1554 }
1555 }
1556
1557 pub fn invalidate_block_caches(&mut self) {
1561 self.layout_params_key = (0, 0, None);
1562 self.block_heights.clear();
1563 self.block_fingerprints.clear();
1564 self.block_y_prefix.clear();
1565 self.glyph_buf_cache.clear();
1566 self.header_label_cache.clear();
1567 self.metadata_line_cache.clear();
1568 }
1569
1570 fn block_fingerprint(block: &Block) -> (u8, usize) {
1572 let status = match block.status {
1573 beyonder_core::BlockStatus::Running => 1,
1574 beyonder_core::BlockStatus::Completed => 2,
1575 _ => 0,
1576 };
1577 let len = match &block.content {
1578 BlockContent::AgentMessage { content_blocks, .. } => content_blocks
1579 .iter()
1580 .map(|cb| match cb {
1581 beyonder_core::ContentBlock::Text { text } => text.len(),
1582 beyonder_core::ContentBlock::Code { code, .. } => code.len(),
1583 beyonder_core::ContentBlock::Thinking { thinking } => thinking.len(),
1584 })
1585 .sum(),
1586 BlockContent::ToolCall { output, error, .. } => {
1587 output.as_ref().map_or(0, |s| s.len()) + error.as_ref().map_or(0, |s| s.len())
1588 }
1589 BlockContent::ShellCommand { output, .. } => output.rows.len(),
1590 BlockContent::Text { text } => text.len(),
1591 _ => 0,
1592 };
1593 (status, len)
1594 }
1595
1596 fn rebuild_block_layout_cache(&mut self) {
1600 let sc = self.scale_factor;
1601 let padding = PADDING * sc;
1602 let gap = GAP * sc;
1603 let phys_font = self.font_size * sc;
1604 let content_w = self.viewport.width - padding * 2.0;
1605
1606 let cw_bits = content_w.to_bits();
1607 let pf_bits = phys_font.to_bits();
1608 let params_key = (cw_bits, pf_bits, self.running_block_idx);
1609 let params_changed = params_key != self.layout_params_key;
1610 if params_changed {
1611 self.layout_params_key = params_key;
1612 }
1613
1614 let n = self.blocks.len();
1615
1616 let prev_len = self.block_heights.len();
1618 self.block_heights.resize(n, 0.0);
1619 self.block_fingerprints.resize(n, (0, 0));
1620
1621 let mut any_changed = params_changed || prev_len != n;
1622
1623 for i in 0..n {
1624 let block = &self.blocks[i];
1625 let fp = Self::block_fingerprint(block);
1626 let is_running = self.running_block_idx == Some(i);
1627 let content_changed = i < prev_len && self.block_fingerprints[i] != fp;
1628 let needs_recompute = params_changed || is_running || i >= prev_len || content_changed;
1629 self.block_fingerprints[i] = fp;
1630 if needs_recompute {
1631 let h = self.block_height(i, block, content_w, phys_font);
1632 if (self.block_heights[i] - h).abs() > 0.01 {
1633 self.block_heights[i] = h;
1634 any_changed = true;
1635 }
1636 }
1637 }
1638
1639 if any_changed || self.block_y_prefix.len() != n + 1 {
1641 self.block_y_prefix.clear();
1642 self.block_y_prefix.reserve(n + 1);
1643 let mut y = padding;
1644 for i in 0..n {
1645 self.block_y_prefix.push(y);
1646 y += self.block_heights[i] + gap;
1647 }
1648 self.block_y_prefix.push(y); }
1650 }
1651
1652 fn first_visible_block(&self, scroll_offset: f32) -> usize {
1654 let gap = GAP * self.scale_factor;
1655 let n = self.blocks.len();
1656 if n == 0 {
1657 return 0;
1658 }
1659 let mut lo = 0usize;
1661 let mut hi = n;
1662 while lo < hi {
1663 let mid = lo + (hi - lo) / 2;
1664 let bottom = self.block_y_prefix[mid] + self.block_heights[mid] + gap;
1665 if bottom <= scroll_offset {
1666 lo = mid + 1;
1667 } else {
1668 hi = mid;
1669 }
1670 }
1671 lo
1672 }
1673
1674 fn layout_blocks(&mut self) -> (Vec<RectInstance>, f32) {
1675 self.rebuild_block_layout_cache();
1676 self.link_rects.clear();
1677 let mut link_rects_local: Vec<([f32; 4], String)> = vec![];
1678 let mut rects = vec![];
1679 let sc = self.scale_factor;
1680 let padding = PADDING * sc;
1681 let phys_font = self.font_size * sc;
1682 let content_w = self.viewport.width - padding * 2.0;
1683
1684 let first = self.first_visible_block(self.viewport.scroll_offset);
1686
1687 for i in first..self.blocks.len() {
1688 let y = self.block_y_prefix[i];
1689 let h = self.block_heights[i];
1690 let sy = self.viewport.content_to_screen_y(y);
1691
1692 if self.viewport.is_visible(y, h) {
1693 let block = &self.blocks[i];
1694 let x = padding;
1695 if let Some(qr) = self.qr_overlays.get(&block.id) {
1697 self.paint_qr_block(qr, x, sy, content_w, &mut rects);
1698 continue;
1699 }
1700 match &block.content {
1701 BlockContent::ShellCommand { .. } => {
1702 render_shell_block(block, x, sy, content_w, h, phys_font, sc, &mut rects);
1703 }
1704 BlockContent::AgentMessage { .. } => {
1705 render_agent_message(block, x, sy, content_w, h, sc, &mut rects);
1706 }
1707 BlockContent::ApprovalRequest { .. } => {
1708 render_approval_block(block, x, sy, content_w, h, sc, &mut rects);
1709 }
1710 _ => {
1711 render_block_background(block, x, sy, content_w, h, &mut rects);
1712 }
1713 }
1714 if let Some(match_pos) = self.search_match_blocks.iter().position(|&mi| mi == i) {
1715 let y_rgb = self.theme.yellow;
1716 let is_current = self.search_current_match == Some(match_pos);
1717 let alpha = if is_current { 0.35 } else { 0.15 };
1718 let col = [
1719 y_rgb[0] as f32 / 255.0,
1720 y_rgb[1] as f32 / 255.0,
1721 y_rgb[2] as f32 / 255.0,
1722 alpha,
1723 ];
1724 rects.push(RectInstance::filled(x, sy, content_w, h, col));
1725 }
1726 let cmd_bar_h = phys_font * 2.8;
1728 let inner_gap = phys_font * 0.4;
1729 let output_pad_x = 4.0 * sc;
1730 let content_x = x + output_pad_x;
1731 let content_y = sy + cmd_bar_h + inner_gap;
1732 let (cell_w, cell_h) = self.terminal_cell_size();
1733 let rect_h = cell_h.ceil();
1734 let rect_w = cell_w.ceil();
1735 if self.running_block_idx == Some(i) && !self.tui_cells.is_empty() {
1736 for (row_idx, row) in self.tui_cells.iter().enumerate() {
1737 let ry = (content_y + row_idx as f32 * cell_h).floor();
1738 if ry > sy + h {
1739 break;
1740 }
1741 for (col_idx, cell) in row.iter().enumerate() {
1742 if let Some(bg) = cell.bg {
1743 let rx = (content_x + col_idx as f32 * cell_w).floor();
1744 rects.push(RectInstance::filled(
1745 rx,
1746 ry,
1747 rect_w,
1748 rect_h,
1749 [bg[0], bg[1], bg[2], 1.0],
1750 ));
1751 }
1752 }
1753 }
1754 let (cur_row, cur_col) = self.tui_cursor;
1756 let cx = (content_x + cur_col as f32 * cell_w).floor();
1757 let cy = (content_y + cur_row as f32 * cell_h).floor();
1758 rects.push(RectInstance::filled(
1759 cx,
1760 cy,
1761 rect_w,
1762 rect_h,
1763 [0.804, 0.835, 0.918, 0.55],
1764 ));
1765 } else if let BlockContent::ShellCommand { output, .. } = &block.content {
1766 let bl = self.theme.blue;
1767 let link_col = [
1768 bl[0] as f32 / 255.0,
1769 bl[1] as f32 / 255.0,
1770 bl[2] as f32 / 255.0,
1771 1.0,
1772 ];
1773 let ul_h = (1.0 * sc).max(1.0);
1774 for (row_idx, row) in output.rows.iter().enumerate() {
1775 let ry = (content_y + row_idx as f32 * cell_h).floor();
1776 if ry > sy + h {
1777 break;
1778 }
1779 for (col_idx, cell) in row.cells.iter().enumerate() {
1780 let rx = (content_x + col_idx as f32 * cell_w).floor();
1781 if let Some(bg) = cell.bg {
1782 rects.push(RectInstance::filled(
1783 rx,
1784 ry,
1785 rect_w,
1786 rect_h,
1787 [
1788 bg.r as f32 / 255.0,
1789 bg.g as f32 / 255.0,
1790 bg.b as f32 / 255.0,
1791 1.0,
1792 ],
1793 ));
1794 }
1795 if let Some(url) = &cell.link {
1796 let ul_y = ry + rect_h - ul_h;
1797 rects.push(RectInstance::filled(rx, ul_y, rect_w, ul_h, link_col));
1798 link_rects_local.push(([rx, ry, rect_w, rect_h], url.clone()));
1799 }
1800 if cell.underline != UnderlineStyle::None || cell.strikethrough {
1801 let fg_rgb = cell
1802 .fg
1803 .map(|c| {
1804 [c.r as f32 / 255.0, c.g as f32 / 255.0, c.b as f32 / 255.0]
1805 })
1806 .unwrap_or(self.theme.text.map(|v| v as f32 / 255.0));
1807 let line_col = [fg_rgb[0], fg_rgb[1], fg_rgb[2], 1.0];
1808 let dim_col = [fg_rgb[0], fg_rgb[1], fg_rgb[2], 0.5];
1809 let dash_col = [fg_rgb[0], fg_rgb[1], fg_rgb[2], 0.75];
1810 let px = sc.max(1.0);
1811 draw_underline(
1812 &mut rects,
1813 rx,
1814 ry,
1815 rect_w,
1816 rect_h,
1817 px,
1818 cell.underline,
1819 line_col,
1820 dim_col,
1821 dash_col,
1822 );
1823 if cell.strikethrough {
1824 let s_y = (ry + rect_h * 0.5).floor();
1825 rects.push(RectInstance::filled(rx, s_y, rect_w, px, line_col));
1826 }
1827 }
1828 }
1829 }
1830 }
1831 if let Some(sel) = &self.text_selection {
1833 match sel {
1834 TextSelection::Shell {
1835 block_idx,
1836 anchor,
1837 cursor,
1838 } if *block_idx == i => {
1839 if let (
1840 Some((c_x, base_y, cell_w, cell_h)),
1841 BlockContent::ShellCommand { output, .. },
1842 ) = (self.shell_output_geom(i), &block.content)
1843 {
1844 let (s, e) = order_rc(*anchor, *cursor);
1845 let tint = [0.40, 0.65, 1.0, 0.35];
1846 let row_max = output.rows.len().saturating_sub(1);
1847 let s_row = s.0.min(row_max);
1848 let e_row = e.0.min(row_max);
1849 for row_idx in s_row..=e_row {
1850 let row = &output.rows[row_idx];
1851 let start_col = if row_idx == s_row { s.1 } else { 0 };
1852 let end_col = if row_idx == e_row {
1853 e.1.min(row.cells.len())
1854 } else {
1855 row.cells.len()
1856 };
1857 if end_col <= start_col {
1858 continue;
1859 }
1860 let rx = (c_x + start_col as f32 * cell_w).floor();
1861 let ry = (base_y + row_idx as f32 * cell_h).floor();
1862 let rw = ((end_col - start_col) as f32 * cell_w).ceil();
1863 rects.push(RectInstance::filled(
1864 rx,
1865 ry,
1866 rw,
1867 cell_h.ceil(),
1868 tint,
1869 ));
1870 }
1871 }
1872 }
1873 TextSelection::Buffer {
1874 block_idx,
1875 anchor,
1876 cursor,
1877 } if *block_idx == i => {
1878 if let Some((x_content, text_y, _buf_w)) = self.agent_buffer_geom(i) {
1879 if let Some((_, _, _, _, _, buf)) =
1880 self.glyph_buf_cache.get(&block.id)
1881 {
1882 let (s, e) = order_cur(*anchor, *cursor);
1883 let tint = [0.40, 0.65, 1.0, 0.35];
1884 let phys_font_local = self.font_size * sc;
1885 let line_h = phys_font_local * 1.4;
1886 let skipped = match &block.content {
1887 BlockContent::AgentMessage { .. } => {
1888 let total = block_content_text(block).lines().count();
1889 let max_vis = ((self.viewport.height / line_h).ceil()
1890 as usize
1891 + 30)
1892 .max(50);
1893 total.saturating_sub(max_vis)
1894 }
1895 _ => 0,
1896 };
1897 let adjusted_text_y = text_y + skipped as f32 * line_h;
1898 for run in buf.layout_runs() {
1899 if let Some((x_off, w_off)) = run.highlight(s, e) {
1900 if w_off <= 0.0 {
1901 continue;
1902 }
1903 let rx = x_content + x_off;
1904 let ry = adjusted_text_y + run.line_top;
1905 rects.push(RectInstance::filled(
1906 rx,
1907 ry,
1908 w_off,
1909 run.line_height,
1910 tint,
1911 ));
1912 }
1913 }
1914 }
1915 }
1916 }
1917 _ => {}
1918 }
1919 }
1920 if self.selected_block == Some(i) {
1922 let cmd_bar_h = phys_font * 2.8;
1923 let inner_gap = phys_font * 0.4;
1924 let (hl_y, hl_h) = if matches!(block.content, BlockContent::ShellCommand { .. })
1925 {
1926 if self.selected_sub_output {
1927 let out_y = sy + cmd_bar_h + inner_gap;
1928 (out_y, (h - cmd_bar_h - inner_gap).max(1.0))
1929 } else {
1930 (sy, cmd_bar_h)
1931 }
1932 } else {
1933 (sy, h)
1934 };
1935 rects.push(
1936 RectInstance::filled(x, hl_y, content_w, hl_h, [0.30, 0.55, 0.90, 0.12])
1937 .with_radius(3.0)
1938 .with_border(1.0, [0.40, 0.65, 1.0, 0.6]),
1939 );
1940 }
1941 } else {
1942 break;
1944 }
1945 }
1946
1947 let total_h = *self.block_y_prefix.last().unwrap_or(&0.0);
1948 self.link_rects.extend(link_rects_local);
1949 (rects, total_h)
1950 }
1951
1952 fn append_bar_rects(&mut self, rects: &mut Vec<RectInstance>) {
1958 let win_w = self.surface_config.width as f32;
1959 let win_h = self.surface_config.height as f32;
1960 let bar_h = self.computed_bar_h;
1961 let bar_y = win_h - bar_h;
1962 let sc = self.scale_factor;
1963 let phys_font = self.font_size * sc;
1964
1965 let bar_bg = if self.input_running {
1967 [0.065, 0.065, 0.100, 1.0_f32]
1968 } else {
1969 self.theme.surface_alt
1970 };
1971 rects.push(RectInstance::filled(0.0, bar_y, win_w, bar_h, bar_bg));
1972 let b = self.theme.border;
1973 rects.push(RectInstance::filled(
1974 0.0,
1975 bar_y,
1976 win_w,
1977 sc.ceil(),
1978 [b[0], b[1], b[2], 0.5],
1979 ));
1980
1981 let pill_hpad = 12.0 * sc;
1983 let pill_gap = 8.0 * sc;
1984 let pill_char_w = phys_font * 0.6 * 0.75;
1985 let pill_h = 22.0 * sc;
1986 let pill_top = bar_y + 14.0 * sc;
1987 let pill_bgs: [[f32; 4]; 3] = [
1988 [0.155, 0.138, 0.068, 1.0],
1989 [0.080, 0.148, 0.080, 1.0],
1990 [0.108, 0.108, 0.198, 1.0],
1991 ];
1992 let pill_borders: [[f32; 4]; 3] = [
1993 [0.976, 0.886, 0.686, 0.75],
1994 [0.651, 0.890, 0.631, 0.75],
1995 [0.706, 0.745, 0.996, 0.75],
1996 ];
1997 let pill_icons = ['\u{e73c}', '\u{e718}', '\u{f07c}'];
1998 let mut new_pill_rects: Vec<[f32; 4]> = Vec::new();
1999 let mut pill_x = 14.0 * sc;
2000 let pills = self.context_pills.clone();
2001 for (i, label) in pills.iter().enumerate() {
2002 let icon = pill_icons.get(i).copied().unwrap_or(' ');
2003 let full_label = format!("{} {}", icon, label);
2004 let pill_w = full_label.chars().count() as f32 * pill_char_w + 2.0 * pill_hpad;
2005 let bg = pill_bgs
2006 .get(i)
2007 .copied()
2008 .unwrap_or([0.192, 0.196, 0.267, 1.0]);
2009 let border = pill_borders
2010 .get(i)
2011 .copied()
2012 .unwrap_or([0.345, 0.357, 0.439, 0.6]);
2013 rects.push(
2014 RectInstance::filled(pill_x, pill_top, pill_w, pill_h, bg)
2015 .with_radius(4.0)
2016 .with_border(1.0, border),
2017 );
2018 new_pill_rects.push([pill_x, pill_top, pill_w, pill_h]);
2019 pill_x += pill_w + pill_gap;
2020 }
2021 self.pill_rects = new_pill_rects;
2022
2023 if !self.agent_model.is_empty() {
2025 let model_label = format!("\u{f135} {}", self.agent_model); let model_font = phys_font * 0.75;
2027 let model_char_w = model_font * 0.6;
2028 let model_w = model_label.chars().count() as f32 * model_char_w + 2.0 * pill_hpad;
2029 let model_x = win_w - model_w - 14.0 * sc;
2030 rects.push(
2031 RectInstance::filled(
2032 model_x,
2033 pill_top,
2034 model_w,
2035 pill_h,
2036 [0.090, 0.065, 0.130, 1.0],
2037 )
2038 .with_radius(4.0)
2039 .with_border(1.0, [0.722, 0.561, 0.957, 0.7]),
2040 );
2041 }
2042
2043 {
2045 let mode_text = self.mode_label.clone();
2046 let mode_w = mode_text.chars().count() as f32 * pill_char_w + 2.0 * pill_hpad;
2047 let mode_h = 20.0 * sc;
2048 let mode_x = 14.0 * sc;
2049 let mode_y = bar_y + bar_h - mode_h - 8.0 * sc;
2050 let mode_bg = match self.mode_label.as_str() {
2051 "shell" => [0.065, 0.095, 0.155, 1.0],
2052 "agent" => [0.095, 0.065, 0.155, 1.0],
2053 _ => [0.098, 0.098, 0.118, 1.0],
2054 };
2055 let mode_border = match self.mode_label.as_str() {
2056 "shell" => [0.537, 0.706, 0.980, 0.8],
2057 "agent" => [0.792, 0.651, 0.988, 0.8],
2058 _ => [0.345, 0.357, 0.439, 0.6],
2059 };
2060 rects.push(
2061 RectInstance::filled(mode_x, mode_y, mode_w, mode_h, mode_bg)
2062 .with_radius(4.0)
2063 .with_border(1.0, mode_border),
2064 );
2065 self.mode_pill_rect = [mode_x, mode_y, mode_w, mode_h];
2066 }
2067
2068 let mut new_dropdown_rects: Vec<[f32; 4]> = Vec::new();
2070 if let Some((pill_idx, ref items, ref hovered)) = self.open_dropdown.clone() {
2071 if let Some(&[px, _py, pw, _ph]) = self.pill_rects.get(pill_idx) {
2072 let item_h = 22.0 * sc;
2073 let dd_w = pw.max(120.0 * sc);
2074 let n = items.len();
2075 let dd_h_total = n as f32 * item_h;
2076 let dd_y_start = bar_y - dd_h_total;
2077 let dd_border = pill_borders
2078 .get(pill_idx)
2079 .copied()
2080 .unwrap_or([0.345, 0.357, 0.439, 0.7]);
2081 rects.push(
2082 RectInstance::filled(px, dd_y_start, dd_w, dd_h_total, self.theme.bg)
2083 .with_radius(4.0)
2084 .with_border(1.0, dd_border),
2085 );
2086 for (i, _item) in items.iter().enumerate() {
2087 let iy = dd_y_start + i as f32 * item_h;
2088 let is_hovered = hovered.map(|h| h == i).unwrap_or(false);
2089 let item_bg = if is_hovered {
2090 pill_bgs
2091 .get(pill_idx)
2092 .copied()
2093 .unwrap_or(self.theme.surface)
2094 } else {
2095 [self.theme.bg[0], self.theme.bg[1], self.theme.bg[2], 0.0]
2096 };
2097 rects.push(RectInstance::filled(px, iy, dd_w, item_h, item_bg));
2098 new_dropdown_rects.push([px, iy, dd_w, item_h]);
2099 }
2100 }
2101 }
2102 self.dropdown_item_rects = new_dropdown_rects;
2103
2104 let mut new_palette_rects: Vec<[f32; 4]> = Vec::new();
2106 if let Some(ref cmds) = self.command_palette.clone() {
2107 if !cmds.is_empty() {
2108 let item_h = 28.0 * sc;
2109 let pal_w = (win_w * 0.6).min(600.0 * sc).max(300.0 * sc);
2110 let pal_x = 14.0 * sc;
2111 let n = cmds.len().min(8);
2112 let pal_h = n as f32 * item_h;
2113 let pal_y = bar_y - pal_h - 4.0 * sc;
2114 let border_col = [
2115 self.theme.border[0],
2116 self.theme.border[1],
2117 self.theme.border[2],
2118 0.8,
2119 ];
2120 rects.push(
2121 RectInstance::filled(pal_x, pal_y, pal_w, pal_h, self.theme.surface_alt)
2122 .with_radius(6.0)
2123 .with_border(1.0, border_col),
2124 );
2125 for i in 0..n {
2126 let iy = pal_y + i as f32 * item_h;
2127 if self.cmd_palette_hovered == Some(i) {
2128 rects.push(RectInstance::filled(
2129 pal_x,
2130 iy,
2131 pal_w,
2132 item_h,
2133 self.theme.surface,
2134 ));
2135 }
2136 new_palette_rects.push([pal_x, iy, pal_w, item_h]);
2137 }
2138 }
2139 }
2140 self.cmd_palette_rects = new_palette_rects;
2141
2142 if !self.input_preedit.is_empty() && !self.input_running && !self.input_all_selected {
2147 let char_w = (phys_font * 0.6).round();
2148 let pre_chars = self.input_preedit.chars().count();
2149 let pre_w = pre_chars as f32 * char_w;
2150 let ul_h = (1.0 * sc).max(1.0);
2151 let [cx, cy, _cw, ch] = self.input_caret_rect;
2152 if ch > 0.0 {
2153 let ul_y = cy + ch - ul_h;
2154 let sky = self.theme.sky;
2155 let ul_col = [
2156 sky[0] as f32 / 255.0,
2157 sky[1] as f32 / 255.0,
2158 sky[2] as f32 / 255.0,
2159 1.0,
2160 ];
2161 rects.push(RectInstance::filled(
2162 cx,
2163 ul_y,
2164 pre_w.max(ul_h),
2165 ul_h,
2166 ul_col,
2167 ));
2168 }
2169 }
2170 }
2171
2172 fn tui_cell_size(&self) -> (f32, f32) {
2173 self.terminal_cell_size()
2174 }
2175
2176 fn layout_tui(&mut self, rects: &mut Vec<RectInstance>) {
2177 self.link_rects.clear();
2178 let (cell_w, cell_h) = self.tui_cell_size();
2179 let pad = TUI_PAD * self.scale_factor;
2180 let bar_y = self.surface_config.height as f32 - pad;
2182
2183 for (row_idx, row) in self.tui_cells.iter().enumerate() {
2184 let row_y = (pad + row_idx as f32 * cell_h).floor();
2185 if row_y >= bar_y {
2186 break;
2187 }
2188 let next_y = (pad + (row_idx + 1) as f32 * cell_h).floor();
2191 let rect_h = (next_y - row_y).max(1.0) + 1.0;
2192 if row.is_empty() {
2193 continue;
2194 }
2195 for (col_idx, cell) in row.iter().enumerate() {
2196 let col_x = (pad + col_idx as f32 * cell_w).floor();
2197 let next_x = (pad + (col_idx + 1) as f32 * cell_w).floor();
2198 let rect_w = (next_x - col_x).max(1.0) + 1.0;
2199 if let Some(bg) = cell.bg {
2201 rects.push(RectInstance::filled(
2202 col_x,
2203 row_y,
2204 rect_w,
2205 rect_h,
2206 [bg[0], bg[1], bg[2], 1.0],
2207 ));
2208 }
2209 if let Some(url) = &cell.link {
2215 let ul_h = (1.0 * self.scale_factor).max(1.0);
2216 let ul_y = row_y + rect_h - ul_h;
2217 let bl = self.theme.blue;
2218 let col = [
2219 bl[0] as f32 / 255.0,
2220 bl[1] as f32 / 255.0,
2221 bl[2] as f32 / 255.0,
2222 1.0,
2223 ];
2224 rects.push(RectInstance::filled(col_x, ul_y, rect_w, ul_h, col));
2225 self.link_rects
2226 .push(([col_x, row_y, rect_w, rect_h], url.as_ref().clone()));
2227 }
2228 if cell.underline != UnderlineStyle::None || cell.strikethrough {
2231 let line_col = [cell.fg[0], cell.fg[1], cell.fg[2], 1.0];
2232 let dim_col = [cell.fg[0], cell.fg[1], cell.fg[2], 0.5];
2233 let dash_col = [cell.fg[0], cell.fg[1], cell.fg[2], 0.75];
2234 let px = (self.scale_factor).max(1.0);
2235 draw_underline(
2236 rects,
2237 col_x,
2238 row_y,
2239 rect_w,
2240 rect_h,
2241 px,
2242 cell.underline,
2243 line_col,
2244 dim_col,
2245 dash_col,
2246 );
2247 if cell.strikethrough {
2248 let s_y = (row_y + rect_h * 0.5).floor();
2249 rects.push(RectInstance::filled(col_x, s_y, rect_w, px, line_col));
2250 }
2251 }
2252 if let Some(geom) = block_char_geom(cell.first_char()) {
2253 let fg = cell.fg;
2254 let col = [fg[0], fg[1], fg[2], 1.0];
2255 for sub in geom {
2256 if sub.rounded {
2257 let side = rect_w.min(rect_h) * 0.55;
2262 let sub_w_px = (sub.w * 2.0 * side).ceil();
2266 let sub_h_px = (sub.h * 2.0 * side).ceil();
2267 let cx = col_x + rect_w * 0.5;
2268 let cy = row_y + rect_h * 0.5;
2269 let sub_x = (cx + (sub.x - 0.5) * 2.0 * side).floor();
2273 let sub_y = (cy + (sub.y - 0.5) * 2.0 * side).floor();
2274 let inst = RectInstance::filled(sub_x, sub_y, sub_w_px, sub_h_px, col);
2275 rects.push(inst.with_radius(side));
2279 } else {
2280 let sub_x = col_x + (sub.x * rect_w).floor();
2281 let sub_y = row_y + (sub.y * rect_h).floor();
2282 let sub_w = (sub.w * rect_w).ceil() + 1.0;
2283 let sub_h = (sub.h * rect_h).ceil() + 1.0;
2284 rects.push(RectInstance::filled(sub_x, sub_y, sub_w, sub_h, col));
2285 }
2286 }
2287 }
2288 }
2289 }
2290 let (cur_row, cur_col) = self.tui_cursor;
2292 let cx = (pad + cur_col as f32 * cell_w).floor();
2293 let cy = (pad + cur_row as f32 * cell_h).floor();
2294 if cy < bar_y {
2295 let cursor_color = [0.804, 0.835, 0.918, 0.55_f32];
2296 match self.tui_cursor_shape {
2297 1 => {
2298 let beam_w = (2.0 * self.scale_factor).max(2.0);
2300 rects.push(RectInstance::filled(
2301 cx,
2302 cy,
2303 beam_w,
2304 cell_h.ceil(),
2305 cursor_color,
2306 ));
2307 }
2308 2 => {
2309 let ul_h = (2.0 * self.scale_factor).max(2.0);
2311 rects.push(RectInstance::filled(
2312 cx,
2313 cy + cell_h.ceil() - ul_h,
2314 cell_w.ceil(),
2315 ul_h,
2316 cursor_color,
2317 ));
2318 }
2319 _ => {
2320 rects.push(RectInstance::filled(
2322 cx,
2323 cy,
2324 cell_w.ceil(),
2325 cell_h.ceil(),
2326 cursor_color,
2327 ));
2328 }
2329 }
2330 }
2331 }
2332
2333 fn build_tui_text_buffers(&mut self) -> TextBufList {
2334 let (cell_w, cell_h) = self.tui_cell_size();
2335 let sc = self.scale_factor;
2336 let pad = TUI_PAD * sc;
2337 let bar_y = self.surface_config.height as f32 - pad;
2339 let phys_font = self.font_size * sc;
2340 let mut results = TextBufList::new();
2341
2342 let cells = std::mem::take(&mut self.tui_cells);
2344 for (row_idx, row) in cells.iter().enumerate() {
2345 if row.is_empty() {
2346 continue;
2347 }
2348 let y = (pad + row_idx as f32 * cell_h).floor();
2350 if y >= bar_y {
2351 break;
2352 }
2353
2354 let runs = self.make_tui_row_runs(row, cell_w, phys_font);
2358 for (buf, x, w, color) in runs {
2359 results.push((buf, pad + x, y, w, cell_h, color));
2360 }
2361 }
2362 self.tui_cells = cells;
2363 results
2364 }
2365
2366 fn build_text_buffers(&mut self) -> (TextBufList, usize) {
2370 let sc = self.scale_factor;
2371 let padding = PADDING * sc;
2372 let phys_font = self.font_size * sc;
2373 let content_w = self.viewport.width - padding * 2.0;
2374 let _line_h = phys_font * 1.4;
2375 let mut results = TextBufList::new();
2376
2377 let first = self.first_visible_block(self.viewport.scroll_offset);
2379
2380 let blocks = std::mem::take(&mut self.blocks);
2382 #[allow(clippy::needless_range_loop)]
2383 for block_idx in first..blocks.len() {
2384 let block = &blocks[block_idx];
2385 let block_t0 = std::time::Instant::now();
2386 let y = self.block_y_prefix.get(block_idx).copied().unwrap_or(0.0);
2387 let h = self.block_heights.get(block_idx).copied().unwrap_or(0.0);
2388 let sy = self.viewport.content_to_screen_y(y);
2389
2390 if self.viewport.is_visible(y, h) {
2391 if self.qr_overlays.contains_key(&block.id) {
2393 continue;
2394 }
2395 let x = padding;
2396 let is_shell = matches!(block.content, BlockContent::ShellCommand { .. });
2398 let is_agent = matches!(block.content, BlockContent::AgentMessage { .. });
2399 let is_plain_text = matches!(block.content, BlockContent::Text { .. });
2400 let cmd_bar_h = if is_shell {
2403 phys_font * 2.8
2404 } else if is_agent || is_plain_text {
2405 0.0
2406 } else {
2407 phys_font * 1.6
2408 };
2409 let inner_gap = if is_agent || is_plain_text {
2410 phys_font * 0.6
2411 } else {
2412 phys_font * 0.4
2413 };
2414 let hdr_pad = 10.0 * sc;
2415 let content_pad = 8.0 * sc;
2416
2417 if let BlockContent::ShellCommand {
2418 input,
2419 cwd,
2420 duration_ms,
2421 ..
2422 } = &block.content
2423 {
2424 let meta_font = phys_font * 0.88;
2426 let meta_line_h = meta_font * 1.4;
2427 let meta_y = sy + 4.0 * sc;
2428 let gen = block.updated_at.timestamp_millis() as u64;
2431 let meta_text = if let Some((cached_gen, cached)) =
2432 self.metadata_line_cache.get(&block.id)
2433 {
2434 if *cached_gen == gen {
2435 cached.clone()
2436 } else {
2437 let m = Self::format_shell_meta(cwd, *duration_ms);
2438 self.metadata_line_cache
2439 .insert(block.id.clone(), (gen, m.clone()));
2440 m
2441 }
2442 } else {
2443 let m = Self::format_shell_meta(cwd, *duration_ms);
2444 self.metadata_line_cache
2445 .insert(block.id.clone(), (gen, m.clone()));
2446 m
2447 };
2448 let meta_color = gc(self.theme.subtext);
2449 let meta_buf = self.make_buffer(
2450 &meta_text,
2451 content_w - hdr_pad * 2.0,
2452 meta_font,
2453 meta_color,
2454 );
2455 results.push((
2456 meta_buf,
2457 x + hdr_pad,
2458 meta_y,
2459 content_w - hdr_pad * 2.0,
2460 meta_line_h,
2461 meta_color,
2462 ));
2463
2464 let cmd_text_y = meta_y + meta_line_h + 3.0 * sc;
2466 let cmd_color = gc(self.theme.text);
2467 let cmd_buf =
2468 self.make_buffer(input, content_w - hdr_pad * 2.0, phys_font, cmd_color);
2469 results.push((
2470 cmd_buf,
2471 x + hdr_pad,
2472 cmd_text_y,
2473 content_w - hdr_pad * 2.0,
2474 phys_font * 1.4,
2475 cmd_color,
2476 ));
2477 } else if !is_agent && !is_plain_text {
2478 let raw_label = self.cached_header_label(block);
2480 let has_tool_output = matches!(&block.content,
2482 BlockContent::ToolCall { output, .. } if output.is_some());
2483 let header_label = if has_tool_output {
2484 let chevron = if self.is_collapsed(block) {
2485 "▶ "
2486 } else {
2487 "▼ "
2488 };
2489 format!("{}{}", chevron, raw_label)
2490 } else {
2491 raw_label
2492 };
2493 let header_color = match block.kind {
2494 BlockKind::Agent => gc(self.theme.blue),
2495 BlockKind::Approval => gc(self.theme.yellow),
2496 BlockKind::Tool => gc(self.theme.teal),
2497 _ => gc(self.theme.subtext),
2498 };
2499 let header_buf = self.make_buffer(
2500 &header_label,
2501 content_w - hdr_pad * 2.0,
2502 phys_font,
2503 header_color,
2504 );
2505 let hdr_text_y = sy + (cmd_bar_h - phys_font * 1.4) * 0.5;
2506 results.push((
2507 header_buf,
2508 x + hdr_pad,
2509 hdr_text_y,
2510 content_w - hdr_pad * 2.0,
2511 phys_font * 1.4,
2512 header_color,
2513 ));
2514 }
2515
2516 let output_top = sy + cmd_bar_h + inner_gap;
2518
2519 let output_pad_x = 4.0 * sc;
2521 if self.running_block_idx == Some(block_idx) && !self.tui_cells.is_empty() {
2522 let (cell_w, cell_h) = self.terminal_cell_size();
2523 let tui_cells = self.tui_cells.clone();
2524 for (row_idx, row) in tui_cells.iter().enumerate() {
2525 if row.is_empty() {
2526 continue;
2527 }
2528 let ry = (output_top + row_idx as f32 * cell_h).floor();
2529 if ry > sy + h {
2530 break;
2531 }
2532 let runs = self.make_tui_row_runs(row, cell_w, phys_font);
2533 for (buf, rx, w, color) in runs {
2534 results.push((buf, x + output_pad_x + rx, ry, w, cell_h, color));
2535 }
2536 }
2537 } else {
2538 match &block.content {
2539 BlockContent::ShellCommand { output, .. } => {
2540 let (cell_w, cell_h) = self.terminal_cell_size();
2542 let content_x = x + output_pad_x;
2543 let base_y = output_top + 2.0 * sc;
2544 for (row_idx, row) in output.rows.iter().enumerate() {
2545 let row_y = (base_y + row_idx as f32 * cell_h).floor();
2546 if row_y > sy + h {
2547 break;
2548 }
2549 let any_visible = row.cells.iter().any(|c| {
2550 let fc = c.grapheme.chars().next().unwrap_or('\0');
2551 fc != ' ' && fc != '\0'
2552 });
2553 if !any_visible {
2554 continue;
2555 }
2556 let tui_row: Vec<TuiCell> = row
2557 .cells
2558 .iter()
2559 .map(|c| TuiCell {
2560 grapheme: c.grapheme.clone(),
2561 fg: c
2562 .fg
2563 .map(|col| {
2564 [
2565 col.r as f32 / 255.0,
2566 col.g as f32 / 255.0,
2567 col.b as f32 / 255.0,
2568 ]
2569 })
2570 .unwrap_or([0.804, 0.835, 0.918]),
2571 bg: c.bg.map(|col| {
2572 [
2573 col.r as f32 / 255.0,
2574 col.g as f32 / 255.0,
2575 col.b as f32 / 255.0,
2576 ]
2577 }),
2578 bold: c.bold,
2579 italic: c.italic,
2580 underline: c.underline,
2581 strikethrough: c.strikethrough,
2582 link: None,
2583 })
2584 .collect();
2585 let runs = self.make_tui_row_runs(&tui_row, cell_w, phys_font);
2586 for (buf, rx, w, color) in runs {
2587 results.push((buf, content_x + rx, row_y, w, cell_h, color));
2588 }
2589 }
2590 }
2591 _ => {
2592 if !self.is_collapsed(block) {
2593 let content_text = block_content_text(block);
2594 let hdr_h = cmd_bar_h;
2595 let content_h = h - hdr_h - content_pad;
2596 let buf_w = content_w - content_pad * 2.0;
2597 let text_y = sy + hdr_h + 4.0 * sc;
2598
2599 let is_running_agent =
2601 is_agent && matches!(block.status, BlockStatus::Running);
2602 let running_tool = block
2603 .agent_id
2604 .as_ref()
2605 .and_then(|id| self.agent_running_tool.get(id))
2606 .cloned();
2607 const FRAMES: [&str; 10] =
2608 ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2609 let spin_color = gc(self.theme.blue);
2610 if is_running_agent && content_text.is_empty() {
2611 let frame = FRAMES[self.spinner_frame as usize];
2613 let label = if let Some(ref tname) = running_tool {
2614 format!("{} {}…", frame, tname)
2615 } else {
2616 frame.to_string()
2617 };
2618 let spin_buf =
2619 self.make_buffer(&label, buf_w, phys_font, spin_color);
2620 let spin_h = phys_font * 1.4;
2621 let spin_x = x + content_pad;
2622 let spin_y = sy + (h - spin_h) * 0.5;
2623 results.push((
2624 spin_buf, spin_x, spin_y, buf_w, spin_h, spin_color,
2625 ));
2626 } else if is_running_agent && running_tool.is_some() {
2627 let frame = FRAMES[self.spinner_frame as usize];
2630 let tname = running_tool.unwrap();
2631 let label = format!("{} {}…", frame, tname);
2632 let spin_buf = self.make_buffer(
2633 &label,
2634 buf_w,
2635 phys_font * 0.9,
2636 spin_color,
2637 );
2638 let spin_h = phys_font * 1.3;
2639 let spin_x = x + content_pad;
2640 let spin_y = sy + h - spin_h - 4.0 * sc;
2642 results.push((
2643 spin_buf, spin_x, spin_y, buf_w, spin_h, spin_color,
2644 ));
2645 }
2646
2647 if !content_text.is_empty() {
2648 let is_user_msg = matches!(
2649 &block.content,
2650 BlockContent::AgentMessage {
2651 role: beyonder_core::MessageRole::User,
2652 ..
2653 }
2654 );
2655 let fallback_color = gc(self.theme.text);
2656 let content_len = content_text.len() as u64;
2658 let bw_bits = buf_w.to_bits();
2659 let pf_bits = phys_font.to_bits();
2660 let vh_bits = self.viewport.height.to_bits();
2661 if is_agent && !is_user_msg {
2662 let cached = self.glyph_buf_cache.remove(&block.id);
2664 let (buf, skipped, cache_len) = match cached {
2665 Some((len, bw, pf, vh, _frame, b))
2666 if len == content_len
2667 && bw == bw_bits
2668 && pf == pf_bits
2669 && vh == vh_bits =>
2670 {
2671 let line_h = phys_font * 1.4;
2673 let max_vis = ((self.viewport.height / line_h)
2674 .ceil()
2675 as usize
2676 + 30)
2677 .max(50);
2678 let total = content_text.lines().count();
2679 (b, total.saturating_sub(max_vis), len)
2680 }
2681 _ => {
2682 let (b, s) = self.make_markdown_buffer(
2683 &content_text,
2684 buf_w,
2685 phys_font,
2686 );
2687 (b, s, content_len)
2688 }
2689 };
2690 let line_h = phys_font * 1.4;
2693 let adjusted_text_y = text_y + skipped as f32 * line_h;
2694 results.push_cached(
2695 (
2696 buf,
2697 x + content_pad,
2698 adjusted_text_y,
2699 buf_w,
2700 content_h.max(1.0),
2701 fallback_color,
2702 ),
2703 (
2704 block.id.clone(),
2705 cache_len,
2706 bw_bits,
2707 pf_bits,
2708 vh_bits,
2709 ),
2710 );
2711 } else {
2712 let content_buf = if is_user_msg {
2713 let col = gc(self.theme.subtext);
2714 self.make_buffer(&content_text, buf_w, phys_font, col)
2715 } else {
2716 let text_color = match block.kind {
2717 BlockKind::Approval => gc(self.theme.peach),
2718 BlockKind::Tool => gc(self.theme.sky),
2719 _ => gc(self.theme.text),
2720 };
2721 self.make_buffer(
2722 &content_text,
2723 buf_w,
2724 phys_font,
2725 text_color,
2726 )
2727 };
2728 results.push((
2729 content_buf,
2730 x + content_pad,
2731 text_y,
2732 buf_w,
2733 content_h.max(1.0),
2734 fallback_color,
2735 ));
2736 }
2737 }
2738 }
2739 }
2740 }
2741 }
2742 let block_ms = block_t0.elapsed().as_millis();
2743 if block_ms > 5 {
2744 debug!(
2745 block_idx,
2746 kind = ?block.kind,
2747 status = ?block.status,
2748 elapsed_ms = block_ms,
2749 "build_text_buffers: slow block"
2750 );
2751 }
2752 } else {
2753 break;
2755 }
2756 }
2757
2758 self.blocks = blocks;
2759
2760 let block_entry_count = results.len();
2762 (results, block_entry_count)
2763 }
2764
2765 fn build_bar_text_buffers(&mut self) -> TextBufList {
2770 let sc = self.scale_factor;
2771 let phys_font = self.font_size * sc;
2772 let win_w = self.surface_config.width as f32;
2773 let win_h = self.surface_config.height as f32;
2774 let bar_h = self.computed_bar_h;
2775 let bar_y = win_h - bar_h;
2776 let h_pad = 14.0 * sc;
2777 let text_x = h_pad;
2778 let text_w = win_w - h_pad * 2.0;
2779 let line_h = phys_font * 1.4;
2780 let text_zone_top = bar_y + 14.0 * sc + 22.0 * sc + 7.0 * sc;
2781 let mode_zone_h = 20.0 * sc + 8.0 * sc;
2782 let remaining_h = bar_h - (text_zone_top - bar_y) - mode_zone_h;
2783 let text_y = text_zone_top + (remaining_h - line_h) * 0.5;
2784
2785 let mut results = TextBufList::new();
2786
2787 if self.input_running {
2788 let text = "running…".to_string();
2789 let col = gc(self.theme.muted);
2790 let buf = self.make_buffer(&text, text_w, phys_font, col);
2791 results.push((buf, text_x, text_y, text_w, line_h, col));
2792 } else if self.input_text.is_empty() {
2793 let caret = if self.cursor_blink_on { "▌" } else { " " };
2794 let caret_col = gc(self.theme.text);
2795 let caret_w = (phys_font * 0.6).round();
2796 let caret_buf = self.make_buffer(caret, caret_w * 2.0, phys_font, caret_col);
2797 results.push((caret_buf, text_x, text_y, caret_w * 2.0, line_h, caret_col));
2798 let ph = "Type anything, beyonder will pick up whether it's a command or prompt";
2799 let ph_col = gc(self.theme.muted);
2800 let ph_x = text_x + caret_w;
2801 let ph_w = (text_w - caret_w).max(1.0);
2802 let ph_buf = self.make_buffer(ph, ph_w, phys_font, ph_col);
2803 results.push((ph_buf, ph_x, text_y, ph_w, line_h, ph_col));
2804 } else {
2805 let cursor = self.input_cursor.min(self.input_text.len());
2806 let before = &self.input_text[..cursor];
2807 let after = &self.input_text[cursor..];
2808 let preedit_active = !self.input_preedit.is_empty();
2809 let (text, col) = if self.input_all_selected {
2810 let t = format!("{}█{}", self.input_mode_prefix, self.input_text);
2813 (t, gc(self.theme.blue))
2814 } else if preedit_active {
2815 let caret = if self.cursor_blink_on { "▌" } else { " " };
2820 let t = format!(
2821 "{}{}{}{}{}",
2822 self.input_mode_prefix, before, self.input_preedit, caret, after
2823 );
2824 (t, gc(self.theme.sky))
2825 } else {
2826 let caret = if self.cursor_blink_on { "▌" } else { " " };
2827 let t = format!("{}{}{}{}", self.input_mode_prefix, before, caret, after);
2828 (t, gc(self.theme.text))
2829 };
2830
2831 let phys_font_local = self.font_size * sc;
2832 let line_h_local = phys_font_local * 1.4;
2833 let visible_lines = self
2834 .measure_input_lines(text_w, phys_font_local)
2835 .0
2836 .min(MAX_INPUT_LINES);
2837 let text_area_h = visible_lines as f32 * line_h_local;
2838
2839 let remaining_h = bar_h - (text_zone_top - bar_y) - mode_zone_h;
2842 let text_block_y = text_zone_top + (remaining_h - text_area_h).max(0.0) * 0.5;
2843
2844 let metrics = glyphon::Metrics::new(phys_font, phys_font * 1.4);
2850 let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
2851 buf.set_size(&mut self.font_system, Some(text_w), Some(text_area_h));
2852 buf.set_text(
2853 &mut self.font_system,
2854 &text,
2855 glyphon::Attrs::new()
2856 .family(glyphon::Family::Name("JetBrainsMono Nerd Font"))
2857 .color(col),
2858 glyphon::Shaping::Advanced,
2859 );
2860 buf.set_scroll(glyphon::cosmic_text::Scroll {
2861 line: 0,
2862 vertical: self.input_scroll_px,
2863 horizontal: 0.0,
2864 });
2865 buf.shape_until_scroll(&mut self.font_system, false);
2866 let run_tops: Vec<f32> = buf.layout_runs().map(|r| r.line_top).collect();
2867 let clip_top = text_block_y as i32;
2868 let clip_bottom = (text_block_y + text_area_h) as i32;
2869 tracing::info!(
2870 input_scroll_px = self.input_scroll_px,
2871 text_block_y,
2872 text_area_h,
2873 clip_top,
2874 clip_bottom,
2875 visible_lines,
2876 ?run_tops,
2877 "bar text layout"
2878 );
2879 results.push_clipped(
2880 (buf, text_x, text_block_y, text_w, text_area_h, col),
2881 (clip_top, clip_bottom),
2882 );
2883
2884 let char_w = (phys_font * 0.6).round();
2887 let prefix_chars = self.input_mode_prefix.chars().count();
2888 let before_chars = self.input_text[..cursor].chars().count();
2889 let caret_x = text_x + (prefix_chars as f32 + before_chars as f32) * char_w;
2890 self.input_caret_rect = [caret_x, text_block_y, char_w.max(2.0), line_h_local];
2891
2892 if !self.input_ghost.is_empty()
2895 && cursor == self.input_text.len()
2896 && !self.input_all_selected
2897 && self.input_preedit.is_empty()
2898 {
2899 let ghost_col = gc(self.theme.muted);
2900 let ghost_x = caret_x + char_w;
2902 let ghost_w = (text_w - (ghost_x - text_x)).max(1.0);
2903 let ghost_buf =
2904 self.make_buffer(&self.input_ghost.clone(), ghost_w, phys_font, ghost_col);
2905 results.push((
2906 ghost_buf,
2907 ghost_x,
2908 text_block_y,
2909 ghost_w,
2910 line_h_local,
2911 ghost_col,
2912 ));
2913 }
2914 let _ = preedit_active;
2915 }
2916
2917 let pill_top = bar_y + 14.0 * sc;
2919 let pill_h = 22.0 * sc;
2920 let pill_hpad = 12.0 * sc;
2921 let pill_gap = 8.0 * sc;
2922 let pill_char_w = phys_font * 0.6 * 0.75;
2923 let pill_font_size = phys_font * 0.75;
2924 let pill_line_h = pill_font_size * 1.4;
2925 let pill_text_y = pill_top + (pill_h - pill_line_h) * 0.5;
2926 let pill_icons = ['\u{e73c}', '\u{e718}', '\u{f07c}'];
2927 let pill_text_colors = [
2928 gc(self.theme.yellow),
2929 gc(self.theme.green),
2930 gc(self.theme.lavender),
2931 ];
2932 let pills = self.context_pills.clone();
2933 let mut pill_x = 14.0 * sc;
2934 for (i, label) in pills.iter().enumerate() {
2935 let icon = pill_icons.get(i).copied().unwrap_or(' ');
2936 let full_label = format!("{} {}", icon, label);
2937 let pill_w = full_label.chars().count() as f32 * pill_char_w + 2.0 * pill_hpad;
2938 let color = pill_text_colors
2939 .get(i)
2940 .copied()
2941 .unwrap_or(gc(self.theme.subtext));
2942 let pill_buf =
2943 self.make_pill_buffer(&full_label, pill_w - 2.0 * pill_hpad, pill_font_size, color);
2944 results.push((
2945 pill_buf,
2946 pill_x + pill_hpad,
2947 pill_text_y,
2948 pill_w - 2.0 * pill_hpad,
2949 pill_line_h,
2950 color,
2951 ));
2952 pill_x += pill_w + pill_gap;
2953 }
2954
2955 if !self.agent_model.is_empty() {
2957 let model_label = format!("\u{f135} {}", self.agent_model);
2958 let model_font = phys_font * 0.75;
2959 let model_char_w = model_font * 0.6;
2960 let pill_hpad = 12.0 * sc;
2961 let model_w = model_label.chars().count() as f32 * model_char_w + 2.0 * pill_hpad;
2962 let model_x = win_w - model_w - 14.0 * sc;
2963 let pill_top = bar_y + 14.0 * sc;
2964 let pill_h = 22.0 * sc;
2965 let model_line_h = model_font * 1.4;
2966 let model_ty = pill_top + (pill_h - model_line_h) * 0.5;
2967 let model_color = gc(self.theme.mauve);
2968 let model_buf = self.make_pill_buffer(
2969 &model_label,
2970 model_w - 2.0 * pill_hpad,
2971 model_font,
2972 model_color,
2973 );
2974 results.push((
2975 model_buf,
2976 model_x + pill_hpad,
2977 model_ty,
2978 model_w - 2.0 * pill_hpad,
2979 model_line_h,
2980 model_color,
2981 ));
2982 }
2983
2984 {
2986 let [mode_x, mode_y, mode_w, mode_h] = self.mode_pill_rect;
2987 if mode_w > 0.0 {
2988 let mode_text = self.mode_label.clone();
2989 let mode_font = phys_font * 0.75;
2990 let mode_line_h = mode_font * 1.4;
2991 let mode_color = match self.mode_label.as_str() {
2992 "shell" => gc(self.theme.blue),
2993 "agent" => gc(self.theme.mauve),
2994 _ => gc(self.theme.muted),
2995 };
2996 let hpad = 12.0 * sc;
2997 let mode_buf =
2998 self.make_pill_buffer(&mode_text, mode_w - 2.0 * hpad, mode_font, mode_color);
2999 let ty = mode_y + (mode_h - mode_line_h) * 0.5;
3000 results.push((
3001 mode_buf,
3002 mode_x + hpad,
3003 ty,
3004 mode_w - 2.0 * hpad,
3005 mode_line_h,
3006 mode_color,
3007 ));
3008 }
3009 }
3010
3011 if let Some((pill_idx, ref items, _)) = self.open_dropdown.clone() {
3013 if let Some(&[px, _py, pw, _ph]) = self.pill_rects.get(pill_idx) {
3014 let item_h = 22.0 * sc;
3015 let item_v_pad = 3.0 * sc;
3016 let dd_w = pw.max(120.0 * sc);
3017 let n = items.len();
3018 let dd_y_start = bar_y - n as f32 * item_h;
3019 let dd_text_colors = [
3020 gc(self.theme.yellow),
3021 gc(self.theme.green),
3022 gc(self.theme.lavender),
3023 ];
3024 let dd_text_color = dd_text_colors
3025 .get(pill_idx)
3026 .copied()
3027 .unwrap_or(gc(self.theme.text));
3028 for (i, item) in items.iter().enumerate() {
3029 let iy = dd_y_start + i as f32 * item_h + item_v_pad;
3030 let item_buf =
3031 self.make_buffer(item, dd_w - pill_hpad * 2.0, phys_font, dd_text_color);
3032 results.push((
3033 item_buf,
3034 px + pill_hpad,
3035 iy,
3036 dd_w - pill_hpad * 2.0,
3037 item_h,
3038 dd_text_color,
3039 ));
3040 }
3041 }
3042 }
3043
3044 if let Some(ref cmds) = self.command_palette.clone() {
3046 let n = cmds.len().min(8);
3047 if n > 0 {
3048 let item_h = 28.0 * sc;
3049 let pal_w = (win_w * 0.6).min(600.0 * sc).max(300.0 * sc);
3050 let pal_x = 14.0 * sc;
3051 let pal_y = bar_y - n as f32 * item_h - 4.0 * sc;
3052 let usage_col = gc(self.theme.lavender);
3053 let desc_col = gc(self.theme.muted);
3054 let pal_font = phys_font * 0.88;
3055 let pal_line_h = pal_font * 1.4;
3056 let v_pad = (item_h - pal_line_h) * 0.5;
3057 let h_pad = 10.0 * sc;
3058 let usage_w = pal_w * 0.38;
3059 let desc_x = pal_x + h_pad + usage_w + 8.0 * sc;
3060 let desc_w = pal_w - usage_w - h_pad * 2.0 - 8.0 * sc;
3061 for (i, (usage, desc)) in cmds.iter().take(n).enumerate() {
3062 let iy = pal_y + i as f32 * item_h + v_pad;
3063 let usage_buf = self.make_buffer(usage, usage_w, pal_font, usage_col);
3064 results.push((usage_buf, pal_x + h_pad, iy, usage_w, pal_line_h, usage_col));
3065 let desc_buf = self.make_buffer(desc, desc_w, pal_font, desc_col);
3066 results.push((desc_buf, desc_x, iy, desc_w, pal_line_h, desc_col));
3067 }
3068 }
3069 }
3070
3071 results
3072 }
3073
3074 fn append_tab_bar_rects(&mut self, rects: &mut Vec<RectInstance>) {
3077 let tab_h = self.tab_bar_height_phys();
3078 if tab_h <= 0.0 || self.tab_labels.is_empty() {
3079 self.tab_rects.clear();
3080 return;
3081 }
3082 let sc = self.scale_factor;
3083 let win_w = self.surface_config.width as f32;
3084 rects.push(RectInstance::filled(
3086 0.0,
3087 0.0,
3088 win_w,
3089 tab_h,
3090 self.theme.surface_alt,
3091 ));
3092 let b = self.theme.border;
3094 rects.push(RectInstance::filled(
3095 0.0,
3096 tab_h - sc.ceil(),
3097 win_w,
3098 sc.ceil(),
3099 [b[0], b[1], b[2], 0.6],
3100 ));
3101
3102 let pad_x = 8.0 * sc;
3103 let gap = 4.0 * sc;
3104 let inner_pad = 12.0 * sc;
3105 let phys_font = self.font_size * sc * 0.85;
3106 let char_w = phys_font * 0.6;
3107 let tab_inner_h = tab_h - 6.0 * sc;
3108 let tab_y = 3.0 * sc;
3109
3110 let mut x = pad_x;
3111 let labels = self.tab_labels.clone();
3112 let active = self.active_tab;
3113 let mut new_rects: Vec<[f32; 4]> = Vec::with_capacity(labels.len());
3114 for (i, label) in labels.iter().enumerate() {
3115 let tab_w = label.chars().count() as f32 * char_w + inner_pad * 2.0;
3116 let is_active = i == active;
3117 let b = self.theme.border;
3118 let bl = self.theme.blue;
3119 let (bg, border) = if is_active {
3120 (
3121 self.theme.surface,
3122 [
3123 bl[0] as f32 / 255.0,
3124 bl[1] as f32 / 255.0,
3125 bl[2] as f32 / 255.0,
3126 0.9_f32,
3127 ],
3128 )
3129 } else {
3130 (self.theme.bg, [b[0], b[1], b[2], 0.5_f32])
3131 };
3132 rects.push(
3133 RectInstance::filled(x, tab_y, tab_w, tab_inner_h, bg)
3134 .with_radius(4.0)
3135 .with_border(1.0, border),
3136 );
3137 new_rects.push([x, tab_y, tab_w, tab_inner_h]);
3138 x += tab_w + gap;
3139 }
3140 self.tab_rects = new_rects;
3141 }
3142
3143 fn build_tab_bar_text_buffers(&mut self, results: &mut TextBufList) {
3145 let tab_h = self.tab_bar_height_phys();
3146 if tab_h <= 0.0 || self.tab_rects.is_empty() {
3147 return;
3148 }
3149 let sc = self.scale_factor;
3150 let phys_font = self.font_size * sc * 0.85;
3151 let line_h = phys_font * 1.4;
3152 let labels = self.tab_labels.clone();
3153 let active = self.active_tab;
3154 let rects = self.tab_rects.clone();
3155 let inner_pad = 12.0 * sc;
3156 for (i, label) in labels.iter().enumerate() {
3157 let Some(&[rx, ry, rw, rh]) = rects.get(i) else {
3158 continue;
3159 };
3160 let color = if i == active {
3161 gc(self.theme.text)
3162 } else {
3163 gc(self.theme.muted)
3164 };
3165 let ty = ry + (rh - line_h) * 0.5;
3166 let buf =
3167 self.make_pill_buffer(label, (rw - inner_pad * 2.0).max(1.0), phys_font, color);
3168 results.push((buf, rx + inner_pad, ty, rw - inner_pad * 2.0, line_h, color));
3169 }
3170 }
3171
3172 fn make_pill_buffer(
3173 &mut self,
3174 text: &str,
3175 max_width: f32,
3176 size: f32,
3177 color: GlyphColor,
3178 ) -> GlyphBuffer {
3179 let metrics = Metrics::new(size, size * 1.4);
3180 let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
3181 buf.set_size(&mut self.font_system, Some(max_width), None);
3182 buf.set_text(
3183 &mut self.font_system,
3184 text,
3185 Attrs::new()
3186 .family(Family::Name("JetBrainsMono Nerd Font"))
3187 .color(color),
3188 Shaping::Advanced,
3189 );
3190 buf.shape_until_scroll(&mut self.font_system, false);
3191 buf
3192 }
3193
3194 fn make_buffer(
3195 &mut self,
3196 text: &str,
3197 max_width: f32,
3198 size: f32,
3199 color: GlyphColor,
3200 ) -> GlyphBuffer {
3201 let metrics = Metrics::new(size, size * 1.4);
3202 let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
3203 buf.set_size(&mut self.font_system, Some(max_width), None);
3204 buf.set_text(
3206 &mut self.font_system,
3207 text,
3208 Attrs::new()
3209 .family(Family::Name("JetBrainsMono Nerd Font"))
3210 .color(color),
3211 Shaping::Advanced,
3212 );
3213 buf.shape_until_scroll(&mut self.font_system, false);
3214 buf
3215 }
3216
3217 fn make_markdown_buffer(
3222 &mut self,
3223 text: &str,
3224 max_width: f32,
3225 size: f32,
3226 ) -> (GlyphBuffer, usize) {
3227 use glyphon::Weight;
3228
3229 let line_h = size * 1.4;
3230 let max_vis_lines = ((self.viewport.height / line_h).ceil() as usize + 30).max(50);
3233 let all_lines: Vec<&str> = text.lines().collect();
3234 let total_lines = all_lines.len();
3235 let skipped_lines = total_lines.saturating_sub(max_vis_lines);
3236 let visible_text: std::borrow::Cow<str> = if skipped_lines > 0 {
3238 std::borrow::Cow::Owned(all_lines[skipped_lines..].join("\n"))
3239 } else {
3240 std::borrow::Cow::Borrowed(text)
3241 };
3242 let text = visible_text.as_ref();
3243
3244 let base_color = gc(self.theme.text);
3245 let heading_color = gc(self.theme.lavender);
3246 let code_color = gc(self.theme.sky);
3247 let bold_color = GlyphColor::rgb(255, 255, 255);
3248 let fence_color = gc(self.theme.green);
3249
3250 let metrics = Metrics::new(size, size * 1.4);
3251 let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
3252 buf.set_size(&mut self.font_system, Some(max_width), None);
3253
3254 let font_name = Family::Name("JetBrainsMono Nerd Font");
3255 let default_attrs = Attrs::new().family(font_name).color(base_color);
3256
3257 let mut spans: Vec<(String, GlyphColor, bool)> = vec![]; let mut in_fence = false;
3261 for (li, line) in text.lines().enumerate() {
3262 let nl = if li > 0 { "\n" } else { "" };
3263
3264 if line.starts_with("```") {
3266 in_fence = !in_fence;
3267 spans.push((format!("{}{}", nl, line), fence_color, false));
3268 continue;
3269 }
3270 if in_fence {
3271 spans.push((format!("{}{}", nl, line), fence_color, false));
3272 continue;
3273 }
3274
3275 if let Some(rest) = line.strip_prefix("### ") {
3277 spans.push((format!("{}{}", nl, rest), heading_color, false));
3278 continue;
3279 }
3280 if let Some(rest) = line.strip_prefix("## ") {
3281 spans.push((format!("{}{}", nl, rest), heading_color, false));
3282 continue;
3283 }
3284 if let Some(rest) = line.strip_prefix("# ") {
3285 spans.push((format!("{}{}", nl, rest), heading_color, false));
3286 continue;
3287 }
3288
3289 let (parse_line, line_pfx) = if line.starts_with("- ") || line.starts_with("* ") {
3291 spans.push((format!("{}• ", nl), base_color, false));
3292 (&line[2..], "")
3293 } else {
3294 (line, nl)
3295 };
3296
3297 parse_inline(
3299 line_pfx, parse_line, base_color, bold_color, code_color, &mut spans,
3300 );
3301 }
3302
3303 let rich: Vec<(String, Attrs)> = spans
3305 .iter()
3306 .map(|(text, color, bold)| {
3307 let mut attrs = Attrs::new().family(font_name).color(*color);
3308 if *bold {
3309 attrs = attrs.weight(Weight::BOLD);
3310 }
3311 (text.clone(), attrs)
3312 })
3313 .collect();
3314
3315 buf.set_rich_text(
3316 &mut self.font_system,
3317 rich.iter().map(|(t, a)| (t.as_str(), *a)),
3318 default_attrs,
3319 Shaping::Advanced,
3320 );
3321 buf.shape_until_scroll(&mut self.font_system, false);
3322 (buf, skipped_lines)
3323 }
3324
3325 fn make_tui_run_buffer(
3332 &mut self,
3333 text: &str,
3334 color: GlyphColor,
3335 phys_font: f32,
3336 _width: f32,
3337 shaping: Shaping,
3338 ) -> GlyphBuffer {
3339 let metrics = Metrics::new(phys_font, self.measured_metrics_line_h);
3343 let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
3344 buf.set_size(&mut self.font_system, None, None);
3346 let font_name = Family::Name("JetBrainsMono Nerd Font");
3347 buf.set_text(
3348 &mut self.font_system,
3349 text,
3350 Attrs::new().family(font_name).color(color),
3351 shaping,
3352 );
3353 buf.shape_until_scroll(&mut self.font_system, false);
3354 buf
3355 }
3356
3357 fn make_tui_row_runs(
3362 &mut self,
3363 cells: &[TuiCell],
3364 cell_w: f32,
3365 phys_font: f32,
3366 ) -> Vec<(GlyphBuffer, f32, f32, GlyphColor)> {
3367 let mut result = Vec::new();
3368 let mut i = 0;
3369 while i < cells.len() {
3370 if cells[i].first_char() == '\0' {
3374 i += 1;
3375 continue;
3376 }
3377 let run_start = i;
3378 let run_fg = cells[i].fg;
3379 while i < cells.len() && cells[i].fg == run_fg && cells[i].first_char() != '\0' {
3381 i += 1;
3382 }
3383 let run_cells = &cells[run_start..i];
3384 let mut text = String::new();
3390 for c in run_cells.iter() {
3391 let fc = c.first_char();
3392 if (fc as u32) < 32 {
3393 text.push(' ');
3394 } else if c.grapheme.chars().count() > 1 {
3395 text.push_str(&c.grapheme);
3397 } else if matches!(fc, '○') {
3398 text.push(fc);
3399 } else if block_char_geom(fc).is_some() {
3400 text.push(' ');
3401 } else {
3402 text.push(fc);
3403 }
3404 }
3405 if text.trim().is_empty() {
3406 continue;
3407 }
3408
3409 let color = GlyphColor::rgb(
3410 (run_fg[0] * 255.0) as u8,
3411 (run_fg[1] * 255.0) as u8,
3412 (run_fg[2] * 255.0) as u8,
3413 );
3414 let x = (run_start as f32 * cell_w).floor();
3415 let mut end_col = i;
3420 while end_col < cells.len() && cells[end_col].first_char() == '\0' {
3421 end_col += 1;
3422 }
3423 let col_span = end_col - run_start;
3424 let w = (col_span as f32 * cell_w).ceil();
3425 let needs_adv = run_cells
3426 .iter()
3427 .any(|c| c.grapheme.chars().any(|ch| ch as u32 > 127));
3428 let shaping = if needs_adv {
3429 Shaping::Advanced
3430 } else {
3431 Shaping::Basic
3432 };
3433 let buf = self.make_tui_run_buffer(&text, color, phys_font, w, shaping);
3434 result.push((buf, x, w, color));
3435 }
3436 result
3437 }
3438}
3439
3440#[allow(clippy::too_many_arguments)]
3444fn draw_underline(
3445 rects: &mut Vec<RectInstance>,
3446 x: f32,
3447 y: f32,
3448 w: f32,
3449 h: f32,
3450 px: f32,
3451 style: UnderlineStyle,
3452 fg: [f32; 4],
3453 dim: [f32; 4],
3454 dash: [f32; 4],
3455) {
3456 let base_y = y + h - px;
3457 match style {
3458 UnderlineStyle::None => {}
3459 UnderlineStyle::Single => {
3460 rects.push(RectInstance::filled(x, base_y, w, px, fg));
3461 }
3462 UnderlineStyle::Double => {
3463 let gap = px;
3464 let upper = base_y - gap - px;
3465 rects.push(RectInstance::filled(x, upper, w, px, fg));
3466 rects.push(RectInstance::filled(x, base_y, w, px, fg));
3467 }
3468 UnderlineStyle::Curly => {
3469 let thick = (px * 2.0).max(2.0);
3471 rects.push(RectInstance::filled(x, base_y - px, w, thick, fg));
3472 }
3473 UnderlineStyle::Dotted => {
3474 rects.push(RectInstance::filled(x, base_y, w, px, dim));
3475 }
3476 UnderlineStyle::Dashed => {
3477 rects.push(RectInstance::filled(x, base_y, w, px, dash));
3478 }
3479 }
3480}
3481
3482#[derive(Copy, Clone)]
3486struct SubRect {
3487 x: f32,
3488 y: f32,
3489 w: f32,
3490 h: f32,
3491 rounded: bool,
3492}
3493
3494const fn sr(x: f32, y: f32, w: f32, h: f32) -> SubRect {
3495 SubRect {
3496 x,
3497 y,
3498 w,
3499 h,
3500 rounded: false,
3501 }
3502}
3503const fn sc(x: f32, y: f32, w: f32, h: f32) -> SubRect {
3504 SubRect {
3505 x,
3506 y,
3507 w,
3508 h,
3509 rounded: true,
3510 }
3511}
3512
3513fn block_char_geom(ch: char) -> Option<&'static [SubRect]> {
3518 const FULL: &[SubRect] = &[sr(0.0, 0.0, 1.0, 1.0)];
3519 const UPPER: &[SubRect] = &[sr(0.0, 0.0, 1.0, 0.5)];
3520 const LOWER: &[SubRect] = &[sr(0.0, 0.5, 1.0, 0.5)];
3521 const LEFT: &[SubRect] = &[sr(0.0, 0.0, 0.5, 1.0)];
3522 const RIGHT: &[SubRect] = &[sr(0.5, 0.0, 0.5, 1.0)];
3523 const QUL: &[SubRect] = &[sr(0.0, 0.0, 0.5, 0.5)];
3524 const QUR: &[SubRect] = &[sr(0.5, 0.0, 0.5, 0.5)];
3525 const QLL: &[SubRect] = &[sr(0.0, 0.5, 0.5, 0.5)];
3526 const QLR: &[SubRect] = &[sr(0.5, 0.5, 0.5, 0.5)];
3527 const Q_UL_LR: &[SubRect] = &[sr(0.0, 0.0, 0.5, 0.5), sr(0.5, 0.5, 0.5, 0.5)];
3528 const Q_UR_LL: &[SubRect] = &[sr(0.5, 0.0, 0.5, 0.5), sr(0.0, 0.5, 0.5, 0.5)];
3529 const Q_UL_LOWER: &[SubRect] = &[sr(0.0, 0.0, 0.5, 0.5), sr(0.0, 0.5, 1.0, 0.5)];
3530 const Q_UPPER_LL: &[SubRect] = &[sr(0.0, 0.0, 1.0, 0.5), sr(0.0, 0.5, 0.5, 0.5)];
3531 const Q_UPPER_LR: &[SubRect] = &[sr(0.0, 0.0, 1.0, 0.5), sr(0.5, 0.5, 0.5, 0.5)];
3532 const Q_UR_LOWER: &[SubRect] = &[sr(0.5, 0.0, 0.5, 0.5), sr(0.0, 0.5, 1.0, 0.5)];
3533 const E1: &[SubRect] = &[sr(0.0, 0.875, 1.0, 0.125)];
3534 const E2: &[SubRect] = &[sr(0.0, 0.75, 1.0, 0.25)];
3535 const E3: &[SubRect] = &[sr(0.0, 0.625, 1.0, 0.375)];
3536 const E5: &[SubRect] = &[sr(0.0, 0.375, 1.0, 0.625)];
3537 const E6: &[SubRect] = &[sr(0.0, 0.25, 1.0, 0.75)];
3538 const E7: &[SubRect] = &[sr(0.0, 0.125, 1.0, 0.875)];
3539 const V1: &[SubRect] = &[sr(0.0, 0.0, 0.125, 1.0)];
3540 const V2: &[SubRect] = &[sr(0.0, 0.0, 0.25, 1.0)];
3541 const V3: &[SubRect] = &[sr(0.0, 0.0, 0.375, 1.0)];
3542 const V5: &[SubRect] = &[sr(0.0, 0.0, 0.625, 1.0)];
3543 const V6: &[SubRect] = &[sr(0.0, 0.0, 0.75, 1.0)];
3544 const V7: &[SubRect] = &[sr(0.0, 0.0, 0.875, 1.0)];
3545 const DOT: &[SubRect] = &[sc(0.0, 0.0, 1.0, 1.0)];
3549 const DOT_L: &[SubRect] = &[sc(0.0, 0.0, 0.5, 1.0)];
3550 const DOT_R: &[SubRect] = &[sc(0.5, 0.0, 0.5, 1.0)];
3551 const DOT_U: &[SubRect] = &[sc(0.0, 0.0, 1.0, 0.5)];
3552 const DOT_D: &[SubRect] = &[sc(0.0, 0.5, 1.0, 0.5)];
3553 const EMPTY: &[SubRect] = &[];
3554 match ch {
3555 '█' => Some(FULL),
3556 '▀' => Some(UPPER),
3557 '▄' => Some(LOWER),
3558 '▌' => Some(LEFT),
3559 '▐' => Some(RIGHT),
3560 '▘' => Some(QUL),
3561 '▝' => Some(QUR),
3562 '▖' => Some(QLL),
3563 '▗' => Some(QLR),
3564 '▚' => Some(Q_UL_LR),
3565 '▞' => Some(Q_UR_LL),
3566 '▙' => Some(Q_UL_LOWER),
3567 '▛' => Some(Q_UPPER_LL),
3568 '▜' => Some(Q_UPPER_LR),
3569 '▟' => Some(Q_UR_LOWER),
3570 '▁' => Some(E1),
3571 '▂' => Some(E2),
3572 '▃' => Some(E3),
3573 '▅' => Some(E5),
3574 '▆' => Some(E6),
3575 '▇' => Some(E7),
3576 '▏' => Some(V1),
3577 '▎' => Some(V2),
3578 '▍' => Some(V3),
3579 '▋' => Some(V5),
3580 '▊' => Some(V6),
3581 '▉' => Some(V7),
3582 '⏺' | '●' => Some(DOT),
3583 '○' => Some(EMPTY),
3584 '◐' => Some(DOT_L),
3585 '◑' => Some(DOT_R),
3586 '◓' => Some(DOT_U),
3587 '◒' => Some(DOT_D),
3588 _ => None,
3589 }
3590}
3591
3592fn format_duration(ms: u64) -> String {
3597 if ms < 1_000 {
3598 format!("{}ms", ms)
3599 } else if ms < 60_000 {
3600 format!("{:.1}s", ms as f32 / 1000.0)
3601 } else {
3602 format!("{}m{}s", ms / 60_000, (ms % 60_000) / 1_000)
3603 }
3604}
3605
3606fn block_header_label(block: &Block) -> String {
3607 match &block.content {
3608 BlockContent::ShellCommand { input, .. } => input.clone(),
3609 BlockContent::AgentMessage { role, .. } => {
3610 let role_str = match role {
3611 beyonder_core::MessageRole::Assistant => "agent",
3612 beyonder_core::MessageRole::User => "user",
3613 beyonder_core::MessageRole::System => "system",
3614 };
3615 let agent = block
3616 .agent_id
3617 .as_ref()
3618 .map(|a| {
3619 a.0.split('-').next().unwrap_or(&a.0).to_string()
3621 })
3622 .unwrap_or_else(|| role_str.to_string());
3623 format!("◆ {}", agent)
3624 }
3625 BlockContent::ApprovalRequest { action, .. } => {
3626 format!("⚠ Approval Required: {}", action_summary(action))
3627 }
3628 BlockContent::ToolCall {
3629 tool_name, input, ..
3630 } => {
3631 if tool_name == "shell.exec" {
3632 input
3633 .get("cmd")
3634 .and_then(|v| v.as_str())
3635 .unwrap_or("shell")
3636 .to_string()
3637 } else {
3638 let detail = input
3639 .get("path")
3640 .or_else(|| input.get("url"))
3641 .or_else(|| input.get("query"))
3642 .and_then(|v| v.as_str())
3643 .unwrap_or("");
3644 if detail.is_empty() {
3645 format!("⚙ {}", tool_name)
3646 } else {
3647 format!("⚙ {} {}", tool_name, detail)
3648 }
3649 }
3650 }
3651 BlockContent::PlanNode { description, .. } => {
3652 format!("◎ Plan: {}", description)
3653 }
3654 BlockContent::FileEdit { path, .. } => {
3655 format!("~ Edit: {}", path.display())
3656 }
3657 BlockContent::Text { text } => text.chars().take(60).collect(),
3658 }
3659}
3660
3661fn block_content_text(block: &Block) -> String {
3662 match &block.content {
3663 BlockContent::ShellCommand { output, .. } => output
3664 .rows
3665 .iter()
3666 .map(|row| {
3667 row.cells
3668 .iter()
3669 .map(|c| c.grapheme.as_str())
3670 .collect::<String>()
3671 })
3672 .collect::<Vec<_>>()
3673 .join("\n"),
3674 BlockContent::AgentMessage { content_blocks, .. } => content_blocks
3675 .iter()
3676 .map(|cb| match cb {
3677 beyonder_core::ContentBlock::Text { text } => text.clone(),
3678 beyonder_core::ContentBlock::Code { code, language } => {
3679 let lang = language.as_deref().unwrap_or("");
3680 format!("```{}\n{}\n```", lang, code)
3681 }
3682 beyonder_core::ContentBlock::Thinking { thinking } => {
3683 format!("<thinking>{}</thinking>", thinking)
3684 }
3685 })
3686 .collect::<Vec<_>>()
3687 .join("\n"),
3688 BlockContent::ApprovalRequest {
3689 action, reasoning, ..
3690 } => {
3691 let mut text = action_detail(action);
3692 if let Some(r) = reasoning {
3693 text.push('\n');
3694 text.push_str(r);
3695 }
3696 text
3697 }
3698 BlockContent::ToolCall { output, error, .. } => {
3699 if let Some(out) = output {
3700 out.clone()
3701 } else if let Some(e) = error {
3702 e.clone()
3703 } else {
3704 String::new()
3705 }
3706 }
3707 BlockContent::Text { text } => text.clone(),
3708 _ => String::new(),
3709 }
3710}
3711
3712fn action_summary(action: &beyonder_core::AgentAction) -> String {
3713 match action {
3714 beyonder_core::AgentAction::FileWrite { path, .. } => {
3715 format!("Write {}", path.display())
3716 }
3717 beyonder_core::AgentAction::FileRead { path } => {
3718 format!("Read {}", path.display())
3719 }
3720 beyonder_core::AgentAction::FileDelete { path } => {
3721 format!("Delete {}", path.display())
3722 }
3723 beyonder_core::AgentAction::ShellExecute { command } => {
3724 format!("Run `{}`", command)
3725 }
3726 beyonder_core::AgentAction::NetworkRequest { url, method } => {
3727 format!("{} {}", method, url)
3728 }
3729 beyonder_core::AgentAction::AgentSpawn { agent_name } => {
3730 format!("Spawn agent `{}`", agent_name)
3731 }
3732 beyonder_core::AgentAction::ToolUse { tool_name } => {
3733 format!("Use tool `{}`", tool_name)
3734 }
3735 }
3736}
3737
3738fn action_detail(action: &beyonder_core::AgentAction) -> String {
3739 action_summary(action)
3741}
3742
3743fn parse_inline(
3746 prefix: &str,
3747 line: &str,
3748 base: GlyphColor,
3749 bold: GlyphColor,
3750 code: GlyphColor,
3751 out: &mut Vec<(String, GlyphColor, bool)>,
3752) {
3753 if line.is_empty() {
3755 if !prefix.is_empty() {
3756 out.push((prefix.to_string(), base, false));
3757 }
3758 return;
3759 }
3760 let mut pfx = prefix;
3761 let mut rest = line;
3762 while !rest.is_empty() {
3763 if let Some(after) = rest.strip_prefix("**") {
3764 if let Some(end) = after.find("**") {
3765 out.push((format!("{}{}", pfx, &after[..end]), bold, true));
3766 pfx = "";
3767 rest = &after[end + 2..];
3768 continue;
3769 }
3770 }
3771 if let Some(after) = rest.strip_prefix('`') {
3772 if let Some(end) = after.find('`') {
3773 out.push((format!("{}{}", pfx, &after[..end]), code, false));
3774 pfx = "";
3775 rest = &after[end + 1..];
3776 continue;
3777 }
3778 }
3779 let next = rest
3781 .find("**")
3782 .unwrap_or(rest.len())
3783 .min(rest.find('`').unwrap_or(rest.len()));
3784 if next == 0 {
3785 let skip = if rest.starts_with("**") { 2 } else { 1 };
3789 let skip = skip.min(rest.len());
3790 out.push((format!("{}{}", pfx, &rest[..skip]), base, false));
3791 pfx = "";
3792 rest = &rest[skip..];
3793 } else {
3794 out.push((format!("{}{}", pfx, &rest[..next]), base, false));
3795 pfx = "";
3796 rest = &rest[next..];
3797 }
3798 }
3799}
3800
3801#[cfg(test)]
3802mod tests {
3803 use super::Renderer;
3804
3805 #[test]
3809 fn prefix_sum_binary_search_finds_first_visible() {
3810 let padding = 4.0f32;
3811 let gap = 2.0f32;
3812 let heights: Vec<f32> = vec![50.0, 30.0, 80.0, 40.0, 60.0, 100.0, 20.0, 70.0];
3813
3814 let mut prefix = Vec::with_capacity(heights.len() + 1);
3816 let mut y = padding;
3817 for h in &heights {
3818 prefix.push(y);
3819 y += h + gap;
3820 }
3821 prefix.push(y); let first_visible = |scroll_offset: f32| -> usize {
3825 let n = heights.len();
3826 let mut lo = 0usize;
3827 let mut hi = n;
3828 while lo < hi {
3829 let mid = lo + (hi - lo) / 2;
3830 let bottom = prefix[mid] + heights[mid] + gap;
3831 if bottom <= scroll_offset {
3832 lo = mid + 1;
3833 } else {
3834 hi = mid;
3835 }
3836 }
3837 lo
3838 };
3839
3840 assert_eq!(first_visible(0.0), 0);
3842
3843 assert_eq!(first_visible(56.0), 1);
3846
3847 let block2_y = prefix[2]; assert!((block2_y - 88.0).abs() < 0.01);
3850 assert_eq!(first_visible(block2_y), 2);
3851
3852 let total = *prefix.last().unwrap();
3854 assert_eq!(first_visible(total), heights.len());
3855
3856 for i in 1..prefix.len() {
3858 assert!(prefix[i] > prefix[i - 1]);
3859 }
3860 }
3861
3862 #[test]
3864 fn block_top_y_matches_prefix() {
3865 let padding = 4.0f32;
3866 let gap = 2.0f32;
3867 let heights = [100.0f32, 200.0, 50.0];
3868 let mut prefix = vec![];
3869 let mut y = padding;
3870 for h in &heights {
3871 prefix.push(y);
3872 y += h + gap;
3873 }
3874 prefix.push(y);
3875
3876 assert!((prefix[0] - 4.0).abs() < 0.01);
3877 assert!((prefix[1] - 106.0).abs() < 0.01); assert!((prefix[2] - 308.0).abs() < 0.01); assert!((prefix[3] - 360.0).abs() < 0.01); }
3881
3882 #[test]
3885 fn mem_take_roundtrip_preserves_data() {
3886 let mut data = vec![1, 2, 3, 4, 5];
3887 let taken = std::mem::take(&mut data);
3888 assert!(data.is_empty());
3889 assert_eq!(taken, vec![1, 2, 3, 4, 5]);
3890 data = taken;
3891 assert_eq!(data, vec![1, 2, 3, 4, 5]);
3892 }
3893
3894 #[test]
3895 fn format_shell_meta_abbreviates_home() {
3896 let home = std::env::var("HOME").unwrap_or_default();
3897 if home.is_empty() {
3898 return; }
3900 let cwd = std::path::PathBuf::from(&home).join("Projects/foo");
3901 let meta = Renderer::format_shell_meta(&cwd, None);
3902 assert!(meta.starts_with("~/"), "should abbreviate HOME: got {meta}");
3903 assert!(meta.contains("Projects/foo"));
3904 }
3905
3906 #[test]
3907 fn format_shell_meta_includes_duration() {
3908 let cwd = std::path::PathBuf::from("/tmp");
3909 let meta = Renderer::format_shell_meta(&cwd, Some(1500));
3910 assert!(meta.contains("/tmp"), "should contain cwd");
3911 assert!(meta.contains("1.5"), "should contain formatted duration");
3912 }
3913
3914 #[test]
3915 fn block_header_label_caches_by_generation() {
3916 use beyonder_core::*;
3917 use std::collections::HashMap;
3918
3919 let bid = BlockId::new();
3920 let mut cache: HashMap<BlockId, (u64, String)> = HashMap::new();
3921
3922 let gen = 100u64;
3924 assert!(!cache.contains_key(&bid));
3925 cache.insert(bid.clone(), (gen, "⚙ test_tool".to_string()));
3926
3927 let entry = cache.get(&bid).unwrap();
3929 assert_eq!(entry.0, gen);
3930 assert_eq!(entry.1, "⚙ test_tool");
3931
3932 let new_gen = 200u64;
3934 let entry = cache.get(&bid).unwrap();
3935 assert_ne!(entry.0, new_gen);
3936 cache.insert(bid.clone(), (new_gen, "⚙ updated".to_string()));
3937 assert_eq!(cache.get(&bid).unwrap().1, "⚙ updated");
3938 }
3939
3940 #[test]
3941 fn home_env_cached_via_once_lock() {
3942 let cwd = std::path::PathBuf::from("/tmp/test");
3944 let a = Renderer::format_shell_meta(&cwd, None);
3945 let b = Renderer::format_shell_meta(&cwd, None);
3946 assert_eq!(a, b, "same input should produce same output");
3947 }
3948
3949 #[test]
3950 fn lru_eviction_removes_stale_entries() {
3951 use std::collections::HashMap;
3952 type Cache = HashMap<String, (u64, u64)>; let mut cache = Cache::new();
3955 const EVICT_AGE: u64 = 120;
3956
3957 cache.insert("a".into(), (1, 10)); cache.insert("b".into(), (2, 50)); cache.insert("c".into(), (3, 130)); let fc: u64 = 140;
3964 cache.retain(|_, (_, last)| fc.saturating_sub(*last) < EVICT_AGE);
3965
3966 assert!(
3967 !cache.contains_key("a"),
3968 "a (frame 10) should be evicted at frame 140"
3969 );
3970 assert!(
3971 cache.contains_key("b"),
3972 "b (frame 50) should survive at frame 140"
3973 );
3974 assert!(
3975 cache.contains_key("c"),
3976 "c (frame 130) should survive at frame 140"
3977 );
3978 }
3979
3980 #[test]
3981 fn lru_eviction_preserves_recently_used() {
3982 use std::collections::HashMap;
3983 type Cache = HashMap<String, (u64, u64)>;
3984 let mut cache = Cache::new();
3985 const EVICT_AGE: u64 = 120;
3986
3987 for i in 0..300u64 {
3989 cache.insert(format!("entry_{i}"), (i, 900 + (i % 10)));
3990 }
3991 let fc: u64 = 910;
3992 cache.retain(|_, (_, last)| fc.saturating_sub(*last) < EVICT_AGE);
3993 assert_eq!(cache.len(), 300);
3995 }
3996}