Skip to main content

beyonder_gpu/
renderer.rs

1//! Main GPU renderer — wgpu pipeline + glyphon text rendering.
2
3use 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
23/// Minimum height of the input bar (one text line + pills + mode pill + padding).
24const INPUT_BAR_HEIGHT: f32 = 120.0;
25/// Maximum number of visible lines in the input text area before it scrolls.
26const MAX_INPUT_LINES: usize = 4;
27/// Logical height of the tab strip when visible (>= 2 tabs).
28const TAB_BAR_HEIGHT: f32 = 28.0;
29/// Horizontal padding around the block stream.
30const PADDING: f32 = 4.0;
31/// Inset (logical px) around TUI fullscreen content — keeps app output off the
32/// window edge. Multiplied by scale_factor for physical px.
33const TUI_PAD: f32 = 8.0;
34/// Vertical gap between blocks.
35const 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/// Drag-selected text range inside a single block.
43#[derive(Clone, Debug)]
44pub enum TextSelection {
45    /// Cell-grid selection over a completed ShellCommand block's output.
46    Shell {
47        block_idx: usize,
48        /// (row, col) at mouse-down.
49        anchor: (usize, usize),
50        /// (row, col) at mouse-up / last drag point.
51        cursor: (usize, usize),
52    },
53    /// Glyph-buffer selection inside an AgentMessage block.
54    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    // Text rendering
98    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    /// Logical font size in points/px. Multiply by scale_factor for physical pixels.
107    pub font_size: f32,
108    /// Active color palette. All drawing functions consult this; swap with `set_theme`.
109    pub theme: beyonder_config::Theme,
110    /// HiDPI scale factor — 2.0 on Retina, 1.0 on standard displays.
111    pub scale_factor: f32,
112    /// Measured cell dimensions (cell_w, cell_h) derived from actual font metrics.
113    /// cell_h = floor(max_ascent + max_descent) — matches swash raster height exactly.
114    /// Cached so terminal_cell_size() doesn't reshape every frame.
115    measured_cell_size: (f32, f32),
116    /// Exact (non-floored) line height for GlyphBuffer Metrics.
117    /// = max_ascent + max_descent as a float, so centering_offset is 0 and glyphs
118    /// sit at the top of each cell without a 1px downward shift from rounding.
119    measured_metrics_line_h: f32,
120    pub blocks: Vec<Block>,
121
122    // Input bar state (synced from App before each render)
123    pub input_text: String,
124    pub input_cursor: usize,
125    pub input_all_selected: bool,
126    pub input_mode_prefix: String,
127    /// Active IME preedit string (displayed at the caret in `self.theme.sky`
128    /// with an underline until the input method commits). Synced from App.
129    pub input_preedit: String,
130    /// Ghost suggestion suffix from history — rendered in muted color after the cursor.
131    pub input_ghost: String,
132    /// Last known caret rect [x, y, w, h] in physical pixels — used by the host
133    /// App to position the IME candidate window via `set_ime_cursor_area`.
134    pub input_caret_rect: [f32; 4],
135
136    /// Cached dynamic bar height in physical pixels — updated once per frame.
137    computed_bar_h: f32,
138    /// Pixel scroll offset applied to the input text (keeps cursor visible when > MAX_INPUT_LINES).
139    input_scroll_px: f32,
140
141    /// Selected block index — highlighted on screen, text copyable via Cmd+C.
142    pub selected_block: Option<usize>,
143    /// For ShellCommand blocks: true = output panel selected, false = cmd bar selected.
144    pub selected_sub_output: bool,
145    /// True while a command is running — input bar shows a "running" indicator.
146    pub input_running: bool,
147
148    pub tui_active: bool,
149    pub tui_cells: Vec<Vec<TuiCell>>,
150    pub tui_cursor: (usize, usize),
151    /// Cursor shape requested by the TUI app (0=block, 1=beam, 2=underline).
152    pub tui_cursor_shape: u8,
153    /// Index of the currently running ShellCommand block (if any). Its content
154    /// area renders live TermGrid cells instead of stored output.
155    pub running_block_idx: Option<usize>,
156
157    // Cursor blink state
158    cursor_blink_on: bool,
159    cursor_last_toggle: Instant,
160
161    // Spinner for streaming agent blocks
162    pub spinner_frame: u8,
163    spinner_last_tick: Instant,
164
165    // Context pills (synced from App before each render)
166    /// Labels for the 3 context pills: [conda, node, dir].
167    pub context_pills: Vec<String>,
168    /// Currently open dropdown: (pill_idx, items, hovered_idx).
169    pub open_dropdown: Option<(usize, Vec<String>, Option<usize>)>,
170    /// Bounding rects [x, y, w, h] for each pill (written during layout).
171    pub pill_rects: Vec<[f32; 4]>,
172    /// OSC 8 hyperlink hit rects: ([x,y,w,h], url). Rebuilt each frame.
173    pub link_rects: Vec<([f32; 4], String)>,
174    /// Bounding rects [x, y, w, h] per dropdown item (written during layout).
175    pub dropdown_item_rects: Vec<[f32; 4]>,
176
177    // Command palette — shown when input starts with /
178    /// Filtered command list: (usage, description). Set by App each frame.
179    pub command_palette: Option<Vec<(String, String)>>,
180    pub cmd_palette_hovered: Option<usize>,
181    /// Hit-test rects for each palette row [x, y, w, h].
182    pub cmd_palette_rects: Vec<[f32; 4]>,
183
184    // Mode switcher — bottom-left of the input bar.
185    pub mode_label: String,
186    pub mode_pill_rect: [f32; 4],
187
188    /// Active agent model name — shown as a pill in the top-right of the input bar.
189    pub agent_model: String,
190
191    /// GlyphBuffer cache: block_id → (content_len, buf_w_bits, font_bits, viewport_h_bits, last_frame, buffer).
192    /// Re-shaping is skipped when content and layout params are unchanged.
193    /// `last_frame` tracks the frame number when this entry was last used, for LRU eviction.
194    glyph_buf_cache: HashMap<beyonder_core::BlockId, (u64, u32, u32, u32, u64, GlyphBuffer)>,
195    /// Monotonic frame counter — incremented each render(). Used for LRU eviction.
196    frame_counter: u64,
197
198    // ── Block layout cache ──────────────────────────────────────────────────
199    /// Cached per-block heights (parallel to `self.blocks`).
200    block_heights: Vec<f32>,
201    /// Content fingerprint per block (status_byte, content_len). When this changes,
202    /// the block height must be recomputed — catches ToolCall Running→Completed and
203    /// agent text appends.
204    block_fingerprints: Vec<(u8, usize)>,
205    /// Prefix-sum of (height + gap) — `block_y_prefix[i]` is the Y offset of block `i`.
206    /// Length = blocks.len() + 1 (sentinel at end = total content height).
207    block_y_prefix: Vec<f32>,
208    /// Generation counter. Incremented when blocks change. Used to detect stale caches.
209    _blocks_generation: u64,
210    /// The layout params (content_w bits, phys_font bits, running_block_idx) used to
211    /// compute cached heights — invalidate if these change (resize, font change).
212    layout_params_key: (u32, u32, Option<usize>),
213
214    /// Cached block header labels: block_id → (content_generation, label).
215    /// Avoids re-formatting "◆ agent", "⚙ tool …", etc. every frame.
216    header_label_cache: HashMap<beyonder_core::BlockId, (u64, String)>,
217    /// Cached block metadata lines (cwd + duration): block_id → (generation, line).
218    metadata_line_cache: HashMap<beyonder_core::BlockId, (u64, String)>,
219
220    /// Currently executing tool per agent: agent_id → tool_name.
221    /// Set by App when ToolCallRequested arrives; cleared on TextDelta or TurnComplete.
222    pub agent_running_tool: HashMap<beyonder_core::AgentId, String>,
223
224    /// Blocks whose default-collapsed state has been overridden by the user.
225    /// A block_id in this set is always shown expanded regardless of collapsed_default.
226    pub user_expanded: HashSet<beyonder_core::BlockId>,
227
228    // Tab strip (synced from App before each render).
229    /// Labels for each tab. Strip is shown only when `tab_labels.len() >= 2`.
230    pub tab_labels: Vec<String>,
231    /// Index of the currently-active tab.
232    pub active_tab: usize,
233    /// Hit rects [x, y, w, h] per tab, written during tab-strip layout.
234    pub tab_rects: Vec<[f32; 4]>,
235
236    /// Indices of blocks that contain a search match — painted with a translucent yellow overlay.
237    pub search_match_blocks: Vec<usize>,
238    /// Index (within `search_match_blocks`) of the currently focused match — painted more opaque.
239    pub search_current_match: Option<usize>,
240
241    /// Drag-based text selection (cell range for shell output, cursor range for agent text).
242    pub text_selection: Option<TextSelection>,
243    /// True while the user is mid-drag (mouse down + dragging).
244    pub selecting: bool,
245
246    /// Blocks that render as a QR bitmap instead of text — rect-painted directly
247    /// so line-height spacing doesn't shatter the modules. Key is the block id
248    /// of a Text block registered via `set_qr_block`; value is the module grid.
249    pub qr_overlays: HashMap<beyonder_core::BlockId, QrBitmap>,
250}
251
252/// QR module bitmap used by `Renderer::set_qr_block`. Row-major, `true` = dark.
253#[derive(Clone, Debug)]
254pub struct QrBitmap {
255    pub width: usize,
256    pub modules: Vec<bool>,
257}
258
259/// Accumulates GlyphBuffer entries for a frame. Parallel `keys` vec records
260/// optional cache metadata so shaped buffers can be returned to the cache
261/// after `TextRenderer::prepare()` consumes the TextArea borrows.
262struct TextBufList {
263    entries: Vec<(GlyphBuffer, f32, f32, f32, f32, GlyphColor)>,
264    /// (block_id, content_len, buf_w_bits, pf_bits, viewport_h_bits)
265    #[allow(clippy::type_complexity)]
266    keys: Vec<Option<(beyonder_core::BlockId, u64, u32, u32, u32)>>,
267    /// Per-entry explicit clip rect override (clip_top, clip_bottom). When Some,
268    /// overrides the default derivation from (y, y+h). Used for scrolled buffers
269    /// where TextArea.top is shifted but the visible clip window differs.
270    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    /// Push a non-cached entry (tuple matches Vec::push call sites unchanged).
283    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    /// Push with an explicit clip rect (clip_top, clip_bottom) in physical pixels.
290    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    /// Push a cacheable entry alongside its invalidation key.
301    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        // Prefer non-sRGB so we can use hex values (sRGB) directly without
353        // linear conversion. With a sRGB surface the GPU would gamma-correct
354        // our values making all colours appear wrong.
355        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        // Glyphon 0.8 setup
378        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        // Use Web color mode: our surface is Bgra8Unorm (non-sRGB) and all colour
399        // values (palette, rects, glyph attrs) are already in sRGB space.
400        // Accurate mode would gamma-decode text colours to linear before upload,
401        // making them darker than rect colours which go through unmodified.
402        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; // logical pixels
417        let scale_factor = window.scale_factor() as f32;
418        let input_bar_h = INPUT_BAR_HEIGHT * scale_factor;
419        // Viewport covers only the block-stream area; input bar at bottom is excluded.
420        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    /// Format shell block metadata line (cwd + duration). Static to avoid borrowing self.
502    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    /// Get or compute a cached block header label.
518    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    /// Physical-pixel height of the tab strip. Zero when fewer than 2 tabs or when a TUI app is active.
549    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    /// Returns the tab index that was clicked at (px, py), if any.
558    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    /// Measure actual cell dimensions from font metrics using cosmic-text layout.
580    /// Uses `max_ascent + max_descent` from a shaped reference string so that
581    /// box-drawing and block-element characters (which are designed to fill the
582    /// exact ascent+descent range) tile without gaps or overlaps between rows.
583    /// Returns (cell_w, cell_h_display, metrics_line_h).
584    /// cell_h_display = floor(max_ascent + max_descent) — matches swash's integer pixel height.
585    /// metrics_line_h = exact float, so GlyphBuffer centering_offset = 0 (glyphs sit at cell top).
586    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; // fallback
605        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                    // floor() matches the integer pixel height swash rasterizes.
612                    // Using ceil() would make rows 1px taller than glyphs, causing
613                    // 1px gaps between adjacent box-drawing / block characters.
614                    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    /// Return the block and sub-region hit by a click at screen_y.
630    /// Returns (block_index, is_output_panel).
631    /// For ShellCommand blocks the cmd bar and output panel are separate hit regions.
632    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    /// Plain block index hit-test (ignores sub-regions).
652    pub fn block_index_at(&self, screen_y: f32) -> Option<usize> {
653        self.block_hit_at(screen_y).map(|(i, _)| i)
654    }
655
656    // ── Text selection (drag-to-select) ─────────────────────────────────────
657
658    /// Compute the on-screen top-left of a block's shell-output cell grid (completed blocks only).
659    /// Returns (content_x, base_y, cell_w, cell_h).
660    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    /// Compute the on-screen top-left of an AgentMessage block's text buffer.
679    /// Returns (content_x, text_y, buf_w).
680    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    /// True when a block renders its body as a single plain-text glyph buffer
712    /// (AgentMessage via markdown-shaper, Text via plain shaper). Those are the
713    /// block kinds for which drag-to-select walks cosmic-text Cursors.
714    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; // touch LRU
764        }
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    /// Mouse-down: start a selection at (phys_x, phys_y). Returns true if a selection began.
779    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                // Only start text-selection inside the output area (below the cmd bar).
791                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    /// Mouse-drag: extend the active selection to (phys_x, phys_y). No-op if not selecting.
824    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    /// Mouse-up: stop tracking drag motion but keep the selection until next click/clear.
858    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    /// Register a Text block to be rendered as a QR bitmap (solid rect modules,
868    /// no glyphs). The caller is responsible for pushing a Text block first and
869    /// then calling this with its id.
870    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    /// Compute integer-pixel module size for a QR bitmap.
875    /// Targets ~250 logical px total side; at least 2 physical px per module.
876    fn qr_mod_px(&self, qr: &QrBitmap) -> f32 {
877        let sc = self.scale_factor;
878        let side_modules = (qr.width + 8) as f32; // +8 for 4-module quiet zone each side
879                                                  // Target ~250 logical px → 250*sc physical.
880        let target = 250.0 * sc;
881
882        (target / side_modules).floor().max(2.0)
883    }
884
885    /// Pixel height a QR overlay will occupy for a given content width.
886    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        // Left-align with a small indent (same as block content).
911        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    /// True when there's a non-empty selection (anchor != cursor).
936    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    /// Extract the selected text as a string. Returns None if no non-empty selection.
947    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    /// Returns true if the mode switcher pill at the bottom-left was clicked.
1033    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    /// Returns the index of the pill that was clicked (0=conda, 1=node, 2=dir).
1039    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    /// Returns the index of the command palette row that was clicked.
1050    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    /// Returns the index of the dropdown item that was clicked.
1061    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    /// Returns the index of the dropdown item hovered at the given position.
1072    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    /// Physical size of one terminal cell based on actual font metrics.
1085    /// Derived from `max_ascent + max_descent` of shaped text so that box-drawing
1086    /// and block-element characters tile without gaps between rows.
1087    pub fn terminal_cell_size(&self) -> (f32, f32) {
1088        self.measured_cell_size
1089    }
1090
1091    /// Physical height of the input bar in pixels.
1092    pub fn bar_height_phys(&self) -> f32 {
1093        self.computed_bar_h
1094    }
1095
1096    /// Physical (width, height) of the surface in pixels.
1097    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    /// Scroll the input text viewport by `delta` physical pixels (positive = down / toward newer text).
1105    /// Clamped between 0 and max scroll. Call this when the user scrolls over the input bar.
1106    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    /// Snap input scroll back so the cursor is visible (call after any cursor-moving keystroke).
1120    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    /// Measure how many visual lines the current input text produces when wrapped to text_w,
1143    /// and which visual line the cursor is on. Returns (total_visual_lines, cursor_visual_line).
1144    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        // Byte position of the caret (▌ is 3 UTF-8 bytes; █ is 3 bytes too)
1158        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 this run ends before the caret, the cursor is on a later line.
1174            if run_end < caret_byte_end {
1175                cursor_line = total_lines + 1;
1176            }
1177            total_lines += 1;
1178        }
1179        // Clamp cursor_line in case it went one past due to the loop logic
1180        let cursor_line = cursor_line.min(total_lines.saturating_sub(1));
1181
1182        (total_lines.max(1), cursor_line)
1183    }
1184
1185    /// Recompute `computed_bar_h` and `input_scroll_px` based on current input state.
1186    /// Call once per frame before `append_bar_rects` and `build_bar_text_buffers`.
1187    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        // Bar height = base 1-line height + extra lines.
1199        // Base (120 logical) fits exactly 1 line with centering margins.
1200        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        // Clamp scroll to valid range (content may have shrunk).
1206        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    /// Convert physical-pixel coords inside the TUI grid into 1-based (col, row)
1221    /// for SGR mouse reporting. Returns None if the point is outside the grid.
1222    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    /// Terminal grid dimensions for TUI fullscreen mode (bar hidden — full window).
1246    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    /// Terminal grid dimensions (cols × rows) that fit in the usable area above the input bar.
1257    /// This is the source of truth for PTY sizing on spawn and resize.
1258    /// NOTE: viewport.height is already set to (surface_height - bar_height_phys) by resize(),
1259    /// so we use it directly — do NOT subtract bar_height again here.
1260    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        // Advance cursor blink — toggle every 530 ms.
1274        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        // Advance spinner — advance every 80 ms through 10 braille frames.
1280        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        // Recompute dynamic bar height and scroll offset.
1286        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        // --- Rect layout ---
1316        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        // Only hide the input bar for full-screen TUI apps (nvim, htop) that take over
1329        // the alt-screen — not for every running shell command. Regular commands keep
1330        // the bar visible; input_running shows a "running…" indicator instead.
1331        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        // --- Text layout ---
1339        // Buffers must outlive the TextArea slices, so we build them here.
1340        // Each entry: (buffer, x, y, w, h, color)
1341        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        // When the bar is hidden, text fills the full window.
1362        // When visible, text must not render over the input bar.
1363        let text_clip_bottom = if bar_hidden {
1364            win_h
1365        } else {
1366            win_h - self.computed_bar_h
1367        };
1368        // Block-stream text must not bleed above the tab strip.
1369        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                // Block stream entries are clipped at the bar boundary.
1376                // Bar text uses (y, y+h) unless a clip override was provided
1377                // (e.g. scrolled input text where TextArea.top is shifted).
1378                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        // Re-insert shaped buffers that have cache keys back into the cache.
1420        // text_areas was consumed by prepare() so buf_list.entries is free to move.
1421        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        // Free atlas glyphs that are no longer needed this frame.
1464        self.text_atlas.trim();
1465
1466        // LRU eviction: drop glyph buffer cache entries not used in the last
1467        // 120 frames (~2s at 60fps). Also cap at 256 entries to bound memory.
1468        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    // -------------------------------------------------------------------------
1480    // Layout helpers
1481    // -------------------------------------------------------------------------
1482
1483    /// Height for the running block — sized to fit up to the last non-blank
1484    /// TermGrid row so simple commands don't get a massive 30-row block.
1485    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    /// Height to use for block `idx` — overrides stored-output measurement
1504    /// Returns true if this block is currently shown collapsed (header only).
1505    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    /// Swap the active theme; invalidates color-baked glyph caches.
1517    pub fn set_theme(&mut self, theme: beyonder_config::Theme) {
1518        self.theme = theme;
1519        self.glyph_buf_cache.clear();
1520    }
1521
1522    /// Toggle a tool block open/closed.
1523    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        // Collapsed ↔ expanded changes block height — force layout rebuild.
1530        self.layout_params_key = (0, 0, None);
1531        self.glyph_buf_cache.remove(block_id);
1532    }
1533
1534    /// Content-space Y (top) of block index `idx`, using the layout cache.
1535    /// Returns None if idx out of range.
1536    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    /// Compute height for a single block.
1544    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 // header bar only
1550        } 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    /// Invalidate all block-related caches. Must be called after replacing `self.blocks`
1558    /// externally (e.g. tab switch) so the layout, glyph, and label caches rebuild from
1559    /// the new block set.
1560    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    /// Quick fingerprint of a block's content for cache invalidation.
1571    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    /// Rebuild cached block heights and prefix-sum Y offsets.
1597    /// Only recomputes heights for blocks that have changed (new or different content length).
1598    /// Called at the top of `layout_blocks`.
1599    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        // Resize caches to match block count.
1617        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        // Only rebuild prefix sums if any height changed.
1640        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); // sentinel = total height
1649        }
1650    }
1651
1652    /// Binary search for the first block whose bottom edge is at or below `scroll_offset`.
1653    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        // Find first i where block_y_prefix[i] + block_heights[i] > scroll_offset
1660        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        // Use binary search to skip blocks above the viewport.
1685        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                // QR overlay short-circuits normal block rendering: paint rects and skip.
1696                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                // Cell background rects — live (TermGrid) and completed (stored output).
1727                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                    // Cursor
1755                    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                // Text-selection highlight rects (drag-selected substring within this block).
1832                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                // Selection highlight — for ShellCommand only the clicked sub-rect lights up.
1921                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                // Block is below the viewport — stop iterating.
1943                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    /// Draw the input bar chrome (background, separator, pills, mode pill, dropdown,
1953    /// command palette). Always called regardless of tui_active so the bar is always
1954    /// visible — even when a full-screen TUI app like nvim is running.
1955    /// Also updates self.pill_rects, self.mode_pill_rect, self.dropdown_item_rects,
1956    /// and self.cmd_palette_rects so that text and hit-testing stay in sync.
1957    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        // Bar background + separator.
1966        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        // Context pills.
1982        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        // Model name pill — top-right of the input bar.
2024        if !self.agent_model.is_empty() {
2025            let model_label = format!("\u{f135}  {}", self.agent_model); // rocket icon
2026            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        // Mode switcher pill.
2044        {
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        // Dropdown rects.
2069        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        // Command palette.
2105        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        // IME preedit underline: a thin sky-colored bar under the composing
2143        // run. The composed text itself is painted inline with the input
2144        // (see build_bar_text_buffers) in theme.sky; this underline is the
2145        // subtle "in-composition" hint.
2146        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        // Bar is hidden in TUI mode — fill the full window (minus pad).
2181        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            // Size the rect to exactly the gap to the next row's snapped y — plus 1px
2189            // overlap — so bg rects tile without sub-pixel black seams.
2190            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                // 1) Bg rect (covers whole cell).
2200                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                // 2) Block / quadrant / shade / circle chars: paint fg as
2210                // geometric sub-rects so pixel-art (claude avatar, progress
2211                // bars, indicators) renders sharply regardless of glyph.
2212                // OSC 8 hyperlink: thin underline in theme blue beneath the cell.
2213                // TODO: hover state + click-to-open are follow-up work.
2214                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                // Underline / strikethrough decorations. Use cell.fg as the
2229                // line color — these are ANSI SGR attributes the app set.
2230                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                            // Compute the circle's bounding square from the
2258                            // shorter cell dimension so the dot stays round —
2259                            // a rounded rect with unequal w/h renders as a pill.
2260                            // 0.55 = visual match with iTerm/ghostty ⏺ sizing.
2261                            let side = rect_w.min(rect_h) * 0.55;
2262                            // sub.w / sub.h describe which fraction of the full
2263                            // disc this sub-glyph covers. Scale the square by
2264                            // those fractions and re-anchor within the cell.
2265                            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                            // sub.x/sub.y are in the full-disc reference frame
2270                            // (0..1 == left..right of the virtual bounding box).
2271                            // Shift them so 0.5 maps to the cell center.
2272                            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                            // Full radius = side (half of 2*side) for crisp
2276                            // circles; half-discs become capsules which read
2277                            // as animation frames.
2278                            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        // Cursor — shape depends on what the TUI app requested.
2291        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                    // Beam: 2 logical-px wide bar at left edge of cell.
2299                    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                    // Underline: thin bar at bottom of cell.
2310                    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                    // Block (default).
2321                    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        // Bar is hidden in TUI mode — fill the full window (minus pad).
2338        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        // Take ownership to avoid cloning — returned at the end.
2343        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            // Match the snapped y from layout_tui so text sits exactly on its bg rect.
2349            let y = (pad + row_idx as f32 * cell_h).floor();
2350            if y >= bar_y {
2351                break;
2352            }
2353
2354            // Per-run rendering: each color run is positioned at its exact column pixel
2355            // with a fixed width, so advance-width mismatches for special chars (box-drawing,
2356            // Nerd Font icons) are contained and don't shift subsequent text rightward.
2357            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    /// Build glyphon Buffers for visible blocks plus bar/pill/dropdown text.
2367    /// Returns (entries, block_entry_count) — only the first `block_entry_count`
2368    /// entries should be clipped to `bar_y`.
2369    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        // Use cached layout: skip to first visible block via binary search.
2378        let first = self.first_visible_block(self.viewport.scroll_offset);
2379
2380        // Take ownership to avoid cloning — returned at the end.
2381        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                // QR overlay blocks are painted as rects in layout_blocks; no glyphs.
2392                if self.qr_overlays.contains_key(&block.id) {
2393                    continue;
2394                }
2395                let x = padding;
2396                // Layout constants (physical px).
2397                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                // Agent messages and plain text blocks have no header bar — they're
2401                // raw content (e.g. /help output) and a header would just duplicate the body.
2402                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                    // Meta row: full ~-abbreviated cwd + execution time, above the command.
2425                    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                    // Cache metadata line per block — avoids env::var("HOME") + format!
2429                    // every frame for every visible shell block.
2430                    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                    // Command row (normal font): the shell command itself.
2465                    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                    // Non-shell, non-agent, non-text blocks: single centered header line.
2479                    let raw_label = self.cached_header_label(block);
2480                    // Only output-bearing ToolCall blocks get a collapse chevron.
2481                    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                // Output area starts below the cmd bar + gap.
2517                let output_top = sy + cmd_bar_h + inner_gap;
2518
2519                // Live running block: render TermGrid rows inside the output panel.
2520                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                            // Render per-cell with stored colors (preserves ANSI/TUI colors).
2541                            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                                // Spinner for streaming agent blocks.
2600                                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                                    // No text yet — centred spinner (or "tool_name..." label).
2612                                    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                                    // Content already rendered + tool is executing — show
2628                                    // a compact tool indicator at the bottom of the block.
2629                                    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                                    // Anchor just above the block bottom edge.
2641                                    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                                    // Cache key: (content_len, buf_w bits, phys_font bits, viewport_h bits).
2657                                    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                                        // Try to reuse a previously shaped markdown buffer.
2663                                        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                                                // Content unchanged — reuse shaped buffer as-is.
2672                                                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                                        // Offset text_y down by the skipped lines so the
2691                                        // visible tail renders at the correct screen position.
2692                                        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                // Below viewport — stop.
2754                break;
2755            }
2756        }
2757
2758        self.blocks = blocks;
2759
2760        // Block entries end here. Bar text is appended separately via build_bar_text_buffers.
2761        let block_entry_count = results.len();
2762        (results, block_entry_count)
2763    }
2764
2765    /// Build GlyphBuffers for the input bar chrome: input field, context pills,
2766    /// mode switcher, dropdown items, and command palette.
2767    /// All entries are unclamped (rendered on top of the bar, not clipped at bar_y).
2768    /// MUST be called after append_bar_rects so pill_rects/mode_pill_rect are current.
2769    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                // Render the whole line in accent colour with a block cursor to
2811                // signal "all selected — next keystroke replaces everything".
2812                let t = format!("{}█{}", self.input_mode_prefix, self.input_text);
2813                (t, gc(self.theme.blue))
2814            } else if preedit_active {
2815                // Splice the IME preedit string in at the caret. Fallback path:
2816                // the whole composed line is shown in `theme.sky` so the user
2817                // can tell a composition is active; when commit fires the
2818                // preedit clears and the committed text is inserted for real.
2819                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            // Multi-line centering: place the text block vertically centred in the
2840            // remaining space between the pills row and the mode pill.
2841            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            // Bounded buffer + set_scroll: layout_runs() subtracts scroll.vertical
2845            // from each run's Y, so visible lines have Y ∈ [0, text_area_h] and
2846            // pre-scroll lines have negative Y. TextArea.top = text_block_y maps
2847            // visible lines to screen [text_block_y, text_block_y+text_area_h].
2848            // Clip tightly to that range so negative-Y runs don't bleed into pills.
2849            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            // Approximate caret rect (for IME candidate positioning). Uses the
2885            // monospace char_w since the input font is JetBrains Mono Nerd Font.
2886            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            // Ghost suggestion: render the history suffix in muted color after the caret
2893            // when the cursor is at the end of the input and no text follows it.
2894            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                // Position right after the caret character (1 char_w past caret_x).
2901                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        // Pill text — one entry per pill.
2918        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        // Model name pill text — top-right.
2956        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        // Mode switcher text.
2985        {
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        // Dropdown text.
3012        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        // Command palette text.
3045        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    /// Draw tab-strip backgrounds + updates `self.tab_rects` for hit-testing.
3075    /// No-ops when fewer than 2 tabs or TUI active (tab_bar_height_phys() == 0).
3076    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        // Strip background (Catppuccin Mantle).
3085        rects.push(RectInstance::filled(
3086            0.0,
3087            0.0,
3088            win_w,
3089            tab_h,
3090            self.theme.surface_alt,
3091        ));
3092        // Bottom separator.
3093        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    /// Append tab-strip label text to the given TextBufList.
3144    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        // Advanced required: spinner braille (⠋…), Nerd Font symbols, box-drawing.
3205        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    /// Build a GlyphBuffer for agent markdown text using per-span colors.
3218    /// Handles headings, **bold**, `inline code`, fenced code blocks, and list items.
3219    /// Returns `(buffer, skipped_lines)` — only the last `max_vis_lines` lines of
3220    /// `text` are shaped so reshape cost is O(viewport) not O(total_response).
3221    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        // Shape only the last viewport-height worth of lines + a small lookahead
3231        // buffer so scrolling slightly above the visible area still looks correct.
3232        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        // Work on the visible tail only.
3237        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        // Build spans: (text, Attrs)
3258        let mut spans: Vec<(String, GlyphColor, bool)> = vec![]; // (text, color, bold)
3259
3260        let mut in_fence = false;
3261        for (li, line) in text.lines().enumerate() {
3262            let nl = if li > 0 { "\n" } else { "" };
3263
3264            // Fenced code block toggle.
3265            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            // Heading — strip markers, differentiate by color only.
3276            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            // List item bullet.
3290            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            // Inline spans: **bold** and `code`.
3298            parse_inline(
3299                line_pfx, parse_line, base_color, bold_color, code_color, &mut spans,
3300            );
3301        }
3302
3303        // Convert to rich-text spans for glyphon.
3304        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    /// Build a GlyphBuffer for a single terminal row with per-character colors.
3326    /// Uses `set_rich_text` so each span gets its own fg color, and disables
3327    /// word-wrap with `set_size(None, None)` so terminal rows never reflow.
3328    /// Build a single-color GlyphBuffer for a run of characters.
3329    /// No width constraint — wide/Nerd Font chars would wrap and disappear with a fixed width.
3330    /// The caller positions the buffer at the correct column and TextBounds clips at the run boundary.
3331    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        // Use exact (non-floored) line height so centering_offset = 0.
3340        // This places glyphs at the exact cell top — prevents 1px downward shift
3341        // that breaks tiling of box-drawing and block-element characters.
3342        let metrics = Metrics::new(phys_font, self.measured_metrics_line_h);
3343        let mut buf = GlyphBuffer::new(&mut self.font_system, metrics);
3344        // No width constraint — prevents wrapping. TextBounds.right clips overflow at the run boundary.
3345        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    /// Decompose a row of TuiCells into per-color-run buffers for column-exact rendering.
3358    /// Returns `(buf, x_offset, width, color)` for each non-whitespace run.
3359    /// Each run is positioned at `run_start_col * cell_w` and capped to `run_len * cell_w`,
3360    /// so special-char advance-width mismatch is contained within the run and never accumulates.
3361    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            // Skip null chars — these are wide-char spacer cells that alacritty writes
3371            // in the column adjacent to a 2-cell-wide character. Including them as spaces
3372            // in a run produces a phantom space glyph next to the wide char.
3373            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            // End the run on fg change OR any null cell (spacer boundary).
3380            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            // Replace block/quadrant/circle chars with spaces — they're painted
3385            // as rects in layout_tui so the glyph would double up and misalign.
3386            // Keep hollow circle (○) as a glyph since we don't paint an outline.
3387            // Multi-codepoint graphemes (ZWJ emoji, skin tones, flags) pass through
3388            // unchanged so cosmic-text can shape the full cluster.
3389            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                    // Multi-codepoint grapheme — keep the full cluster verbatim.
3396                    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            // Extend width to cover any '\0' spacer cells immediately following this run.
3416            // Wide characters (e.g. Nerd Font icons) occupy 2 columns — alacritty writes
3417            // a '\0' spacer in the adjacent column. Without this, the icon is clipped at
3418            // the run boundary and the spacer cell appears as a blank colored strip.
3419            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/// Paint an underline beneath a single cell rect. `px` is the line thickness
3441/// in physical pixels (== scale_factor, min 1). Dotted/dashed use alpha-dim
3442/// approximations to avoid segmenting the rect into many tiny quads.
3443#[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            // TODO: approximate sine wave — for now a 2px-tall underline.
3470            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/// Sub-rect within a cell, fractions in [0,1]. Rounded flag draws as a
3483/// rounded rect with radius = min(w,h)/2 — used to approximate filled circles
3484/// for claude's tool-execution indicators (`⏺ ● ○ ◐ ◑ ◒ ◓`).
3485#[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
3513/// Geometry for block / half-block / quadrant / shade / circle Unicode
3514/// characters. Returns a slice of sub-rects to paint with the cell's fg color.
3515/// This guarantees pixel-perfect tiling for pixel-art avatars, progress bars,
3516/// and compact colored indicators that the font's glyph might not render cleanly.
3517fn 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    // Coords are fractions of the virtual disc bounding box (0..1). The
3546    // rounded-rect renderer re-anchors them to a cell-centered square whose
3547    // side == min(cell_w, cell_h) * 0.55 so dots stay round.
3548    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
3592// -------------------------------------------------------------------------
3593// Text extraction helpers
3594// -------------------------------------------------------------------------
3595
3596fn 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                    // Extract just the name part (before the ULID hyphen)
3620                    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    // More verbose version for the approval block body
3740    action_summary(action)
3741}
3742
3743/// Parse inline markdown on a single line and append colored spans.
3744/// Handles: **bold**, `inline code`, and normal text.
3745fn 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    // Empty line — just emit the prefix (carries the newline).
3754    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        // Consume up to the next marker or end of line.
3780        let next = rest
3781            .find("**")
3782            .unwrap_or(rest.len())
3783            .min(rest.find('`').unwrap_or(rest.len()));
3784        if next == 0 {
3785            // rest starts with an unclosed marker — treat it as literal text
3786            // and advance past it to prevent an infinite loop (common during
3787            // streaming when the response is cut off mid-**bold** or mid-`code`).
3788            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 the prefix-sum + binary-search logic used by rebuild_block_layout_cache
3806    /// and first_visible_block. This validates that O(log n) viewport access works
3807    /// correctly without requiring a GPU context.
3808    #[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        // Build prefix sums (same logic as rebuild_block_layout_cache).
3815        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); // sentinel
3822
3823        // Binary search for first block whose bottom edge > scroll_offset.
3824        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        // At scroll=0, first visible is block 0.
3841        assert_eq!(first_visible(0.0), 0);
3842
3843        // Scroll past block 0: block 0 starts at y=4, height=50, bottom=56.
3844        // At scroll=56, block 0 is just above viewport, first visible = 1.
3845        assert_eq!(first_visible(56.0), 1);
3846
3847        // Scroll to where block 2 starts.
3848        let block2_y = prefix[2]; // = 4 + 50 + 2 + 30 + 2 = 88
3849        assert!((block2_y - 88.0).abs() < 0.01);
3850        assert_eq!(first_visible(block2_y), 2);
3851
3852        // Scroll past all blocks.
3853        let total = *prefix.last().unwrap();
3854        assert_eq!(first_visible(total), heights.len());
3855
3856        // Verify prefix sums are monotonically increasing.
3857        for i in 1..prefix.len() {
3858            assert!(prefix[i] > prefix[i - 1]);
3859        }
3860    }
3861
3862    /// Verify that block_top_y returns correct values from the prefix cache.
3863    #[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); // 4 + 100 + 2
3878        assert!((prefix[2] - 308.0).abs() < 0.01); // 106 + 200 + 2
3879        assert!((prefix[3] - 360.0).abs() < 0.01); // 308 + 50 + 2
3880    }
3881
3882    /// Verify that mem::take + put-back preserves data (the pattern used to
3883    /// eliminate per-frame clones of blocks and tui_cells).
3884    #[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; // CI might not have HOME
3899        }
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        // First call — cache miss.
3923        let gen = 100u64;
3924        assert!(!cache.contains_key(&bid));
3925        cache.insert(bid.clone(), (gen, "⚙ test_tool".to_string()));
3926
3927        // Same generation — cache hit.
3928        let entry = cache.get(&bid).unwrap();
3929        assert_eq!(entry.0, gen);
3930        assert_eq!(entry.1, "⚙ test_tool");
3931
3932        // Different generation — cache invalidated.
3933        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        // Calling format_shell_meta twice should use the OnceLock cached HOME.
3943        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        // Simulate the LRU eviction logic from render().
3953        type Cache = HashMap<String, (u64, u64)>; // key → (data, last_frame)
3954        let mut cache = Cache::new();
3955        const EVICT_AGE: u64 = 120;
3956
3957        // Insert entries at various frames.
3958        cache.insert("a".into(), (1, 10)); // used at frame 10
3959        cache.insert("b".into(), (2, 50)); // used at frame 50
3960        cache.insert("c".into(), (3, 130)); // used at frame 130
3961
3962        // Evict at frame 140 — entries older than 120 frames (< frame 20) are stale.
3963        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        // All entries recently used.
3988        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        // All entries within 120 frames of 910, so all survive.
3994        assert_eq!(cache.len(), 300);
3995    }
3996}