Skip to main content

rpi_tui/
tui_alt_screen.rs

1//! TuiAltScreen - Alternate screen TUI with constrained layout.
2//!
3//! Uses the alternate screen buffer for fullscreen mode with a scrollable
4//! transcript and fixed bottom dock, as described in `tui-plan.md`.
5
6use std::any::Any;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Condvar, Mutex};
9use std::thread::JoinHandle;
10use std::time::{Duration, Instant};
11
12use super::component::Component;
13use super::container::Container;
14use super::layout::{
15    composite_tui_line, extract_cursor_position, render_layout_frame,
16    render_layout_frame_reusing_scroll_content, LayoutFrame,
17};
18use super::overlay::OverlayManager;
19use super::scroll_view::ScrollView;
20use super::tui::{OverlayHandle, OverlayOptions, TuiMode, TuiStopOptions, TUI};
21use crate::ansi::{visible_width, CURSOR_MARKER};
22use crate::terminal::{InputEvent, Terminal, TerminalInfo};
23
24/// Symbol for ViewportTUI capability check.
25pub const VIEWPORT_TUI: &[u8] = b"rpi-tui/viewport";
26
27/// Upper bound on the coalesced repaint rate, matching upstream's
28/// `TuiBase.MIN_RENDER_INTERVAL_MS` (16 ms ≈ 60 fps).
29///
30/// A provider streams one `MessageUpdate` per delta, and every delta used to
31/// repaint the *whole* transcript (full markdown rebuild + full layout pass +
32/// a full-viewport terminal write). Throttling that to one frame per interval
33/// is what keeps long, fast output from saturating the terminal — and the
34/// GPU-backed compositor behind it.
35pub const MIN_RENDER_INTERVAL_MS: u64 = 16;
36
37/// Merge an incoming frame request into one that is already parked.
38///
39/// `reuse_scroll_content = false` means "the transcript changed, re-render it";
40/// `true` means "only the viewport/dock moved, the cached transcript is fine".
41/// A parked full render is never downgraded to a cached one, so a streamed
42/// delta that lands inside a pure-scroll frame window is still painted.
43fn merge_pending_render(parked: Option<bool>, reuse_scroll_content: bool) -> Option<bool> {
44    match parked {
45        Some(parked) => Some(parked && reuse_scroll_content),
46        None => Some(reuse_scroll_content),
47    }
48}
49
50/// Coalescing render-scheduler state — the Rust port of upstream's
51/// `TuiBase.{renderRequested, renderTimer, lastRenderAt}` trio.
52struct RenderSchedulerState {
53    /// `None` = no frame is owed. `Some(reuse_scroll_content)` records which
54    /// render variant the parked request needs.
55    pending: Option<bool>,
56    /// Set by [`TuiAltScreen::stop`] to end the scheduler thread.
57    shutdown: bool,
58    /// When the last frame was painted (upstream `lastRenderAt`).
59    last_render_at: Instant,
60}
61
62/// One scheduler per TUI (never global), so a parked frame can only ever wake
63/// the thread that belongs to its own screen.
64struct RenderScheduler {
65    state: Mutex<RenderSchedulerState>,
66    cv: Condvar,
67}
68
69impl RenderScheduler {
70    fn new() -> Self {
71        Self {
72            state: Mutex::new(RenderSchedulerState {
73                pending: None,
74                shutdown: false,
75                last_render_at: Instant::now(),
76            }),
77            cv: Condvar::new(),
78        }
79    }
80
81    /// Record that a frame is owed and wake the scheduler thread.
82    fn park(&self, reuse_scroll_content: bool) {
83        if let Ok(mut state) = self.state.lock() {
84            state.pending = merge_pending_render(state.pending, reuse_scroll_content);
85        }
86        // The predicate is checked under the same mutex the waiter holds, so the
87        // wakeup cannot be lost between the store above and the wait below.
88        self.cv.notify_all();
89    }
90
91    /// Note that a frame was just painted, restarting the throttle window.
92    fn mark_painted(&self) {
93        if let Ok(mut state) = self.state.lock() {
94            state.last_render_at = Instant::now();
95        }
96    }
97
98    /// Take the parked frame, if any.
99    fn take_pending(&self) -> Option<bool> {
100        self.state.lock().ok()?.pending.take()
101    }
102
103    /// Discard any parked frame (an immediate render supersedes it).
104    fn clear_pending(&self) {
105        if let Ok(mut state) = self.state.lock() {
106            state.pending = None;
107        }
108    }
109
110    /// Signal the scheduler thread to exit and reclaim the parked frame.
111    fn shutdown(&self) {
112        if let Ok(mut state) = self.state.lock() {
113            state.shutdown = true;
114            state.pending = None;
115        }
116        self.cv.notify_all();
117    }
118}
119
120/// Alternate screen TUI with application-owned scrolling.
121pub struct TuiAltScreen {
122    /// Serializes the complete diff/render/write transaction. Input, streaming
123    /// events, and loader ticks can request frames from different threads; if
124    /// they race, an older frame can otherwise overwrite `previous_screen`
125    /// after a newer frame has already reached the terminal.
126    render_lock: Mutex<()>,
127    jump_to_end: crate::jump_to_end::JumpToEnd,
128    terminal: Arc<Mutex<Box<dyn Terminal>>>,
129    terminal_proxy: TerminalProxy,
130    container: Container,
131    layout_root: Mutex<Option<Arc<dyn Component>>>,
132    running: Arc<Mutex<bool>>,
133    show_hardware_cursor: Mutex<bool>,
134    clear_on_shrink: Mutex<bool>,
135    focused: Mutex<Option<Arc<dyn Component>>>,
136    full_redraw_count: Mutex<usize>,
137    previous_screen: Mutex<Vec<String>>,
138    scroll_top: Mutex<usize>,
139    stick_to_bottom: Mutex<bool>,
140    current_frame: Mutex<Option<LayoutFrame>>,
141    /// When true, render into the terminal's main buffer and let the terminal
142    /// own scrollback (upstream's default `regular` mode).
143    main_screen_mode: Mutex<bool>,
144    main_previous_width: Mutex<usize>,
145    main_previous_height: Mutex<usize>,
146    main_hardware_row: Mutex<usize>,
147    main_viewport_top: Mutex<usize>,
148    main_previous_cursor: Mutex<Option<(usize, usize)>>,
149    // Input handlers
150    #[allow(dead_code)]
151    input_handler: Mutex<Option<Arc<dyn Fn(InputEvent) + Send + Sync>>>,
152    #[allow(dead_code)]
153    resize_handler: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
154    overlays: Arc<OverlayManager>,
155    render_suspended: Mutex<bool>,
156    /// Coalescing render scheduler (upstream `TuiBase` frame throttle). While
157    /// it is active, `request_render(false)` and
158    /// `request_render_reusing_scroll_content()` park a frame instead of
159    /// repainting, and the scheduler thread paints at most one frame per
160    /// [`MIN_RENDER_INTERVAL_MS`].
161    scheduler: Arc<RenderScheduler>,
162    /// Whether [`TuiAltScreen::start_render_scheduler`] succeeded. When false,
163    /// render requests paint inline (standalone/unit-test behaviour).
164    scheduler_started: AtomicBool,
165    /// The scheduler thread, joined by [`TuiAltScreen::stop`] so a parked frame
166    /// cannot repaint the terminal after shutdown.
167    scheduler_thread: Mutex<Option<JoinHandle<()>>>,
168}
169
170impl TuiAltScreen {
171    /// Create a new alternate screen TUI.
172    pub fn new(
173        terminal: Box<dyn Terminal>,
174        show_hardware_cursor: bool,
175        _log_directory: Option<&str>,
176    ) -> Self {
177        let terminal = Arc::new(Mutex::new(terminal));
178        let terminal_proxy = TerminalProxy {
179            terminal: terminal.clone(),
180        };
181        Self {
182            render_lock: Mutex::new(()),
183            jump_to_end: crate::jump_to_end::JumpToEnd::default(),
184            terminal,
185            terminal_proxy,
186            container: Container::new(),
187            layout_root: Mutex::new(None),
188            running: Arc::new(Mutex::new(false)),
189            show_hardware_cursor: Mutex::new(show_hardware_cursor),
190            clear_on_shrink: Mutex::new(false),
191            focused: Mutex::new(None),
192            full_redraw_count: Mutex::new(0),
193            previous_screen: Mutex::new(Vec::new()),
194            scroll_top: Mutex::new(0),
195            stick_to_bottom: Mutex::new(true),
196            current_frame: Mutex::new(None),
197            main_screen_mode: Mutex::new(false),
198            main_previous_width: Mutex::new(0),
199            main_previous_height: Mutex::new(0),
200            main_hardware_row: Mutex::new(0),
201            main_viewport_top: Mutex::new(0),
202            main_previous_cursor: Mutex::new(None),
203            input_handler: Mutex::new(None),
204            resize_handler: Mutex::new(None),
205            overlays: Arc::new(OverlayManager::new()),
206            render_suspended: Mutex::new(false),
207            scheduler: Arc::new(RenderScheduler::new()),
208            scheduler_started: AtomicBool::new(false),
209            scheduler_thread: Mutex::new(None),
210        }
211    }
212
213    /// Start the coalescing render scheduler — the Rust port of upstream's
214    /// `TuiBase.scheduleRender()` timer. Idempotent.
215    ///
216    /// Every `request_render(false)` / `request_render_reusing_scroll_content()`
217    /// call after this parks a frame instead of repainting immediately, and the
218    /// scheduler thread paints at most one frame per [`MIN_RENDER_INTERVAL_MS`].
219    /// A burst of streamed deltas therefore collapses into a single repaint of
220    /// the transcript instead of one per token.
221    ///
222    /// Hosts that drive their own event loop may instead leave this off and call
223    /// [`TuiAltScreen::flush_pending_render`] from their own tick; without either
224    /// the requests paint inline (the pre-throttle behaviour).
225    ///
226    /// Takes `&Arc<Self>` because the scheduler thread keeps the TUI alive until
227    /// [`TuiAltScreen::stop`] shuts it down; call it after the TUI has been
228    /// wrapped in the `Arc` the host renders through.
229    pub fn start_render_scheduler(self: &Arc<Self>) {
230        if self.scheduler_started.swap(true, Ordering::AcqRel) {
231            return;
232        }
233        let tui = self.clone();
234        let spawned = std::thread::Builder::new()
235            .name("rpi-tui-render".to_string())
236            .spawn(move || tui.run_render_scheduler());
237        match spawned {
238            Ok(handle) => {
239                if let Ok(mut slot) = self.scheduler_thread.lock() {
240                    *slot = Some(handle);
241                }
242            }
243            Err(_) => {
244                // A failed spawn must not silently disable repaints: fall back
245                // to the synchronous path rather than parking frames forever.
246                self.scheduler_started.store(false, Ordering::Release);
247            }
248        }
249    }
250
251    /// Scheduler thread body: sleep until a frame is owed, then paint at most one
252    /// frame per [`MIN_RENDER_INTERVAL_MS`]. Idle screens pay no wakeups at all —
253    /// the thread is parked on the condvar.
254    fn run_render_scheduler(self: Arc<Self>) {
255        let interval = Duration::from_millis(MIN_RENDER_INTERVAL_MS);
256        loop {
257            // ---- Wait for a frame to be owed (or shutdown) ----
258            {
259                let Ok(mut state) = self.scheduler.state.lock() else {
260                    return;
261                };
262                while state.pending.is_none() && !state.shutdown {
263                    let Ok(next) = self.scheduler.cv.wait(state) else {
264                        return;
265                    };
266                    state = next;
267                }
268                if state.shutdown {
269                    return;
270                }
271            }
272            // ---- Hold the frame back until the throttle window elapses ----
273            loop {
274                let remaining = {
275                    let Ok(state) = self.scheduler.state.lock() else {
276                        return;
277                    };
278                    match interval.checked_sub(state.last_render_at.elapsed()) {
279                        Some(remaining) => remaining,
280                        None => break,
281                    }
282                };
283                let Ok(state) = self.scheduler.state.lock() else {
284                    return;
285                };
286                if state.shutdown {
287                    return;
288                }
289                let Ok((state, _)) = self.scheduler.cv.wait_timeout(state, remaining) else {
290                    return;
291                };
292                if state.shutdown {
293                    return;
294                }
295            }
296            self.flush_pending_render();
297        }
298    }
299
300    /// Paint a frame parked by [`TUI::request_render`] /
301    /// [`TuiAltScreen::request_render_reusing_scroll_content`].
302    ///
303    /// Returns `true` when a frame was painted, `false` when nothing was owed
304    /// (or the owed frame was dropped because the TUI is stopped or a foreign
305    /// runtime owns the terminal — both of those paths force a fresh full frame
306    /// when they end, so nothing is lost on screen).
307    ///
308    /// The scheduler thread calls this; it is also the deterministic hook a host
309    /// tick or a unit test can use to flush a request synchronously.
310    pub fn flush_pending_render(&self) -> bool {
311        let Some(reuse_scroll_content) = self.scheduler.take_pending() else {
312            return false;
313        };
314        if !self.is_running() || self.is_render_suspended() {
315            return false;
316        }
317        self.do_render(reuse_scroll_content);
318        true
319    }
320
321    /// Park a frame request, or paint it inline when no scheduler is running.
322    fn park_render(&self, reuse_scroll_content: bool) {
323        if !self.scheduler_started.load(Ordering::Acquire) {
324            self.do_render(reuse_scroll_content);
325            return;
326        }
327        self.scheduler.park(reuse_scroll_content);
328    }
329
330    /// Test seam: enable frame parking *without* spawning the scheduler thread,
331    /// so a test can drive [`TuiAltScreen::flush_pending_render`] itself and
332    /// observe the coalescing deterministically.
333    #[cfg(test)]
334    fn enable_render_coalescing(&self) {
335        self.scheduler_started.store(true, Ordering::Release);
336    }
337
338    /// Record the frame timestamp that gates the next coalesced repaint.
339    fn mark_frame_painted(&self) {
340        self.scheduler.mark_painted();
341    }
342
343    /// Use the terminal's main screen and native scrollback instead of the
344    /// constrained alternate-screen viewport. Must be set before start.
345    pub fn set_main_screen_mode(&self, enabled: bool) {
346        if !self.is_running() {
347            *self.main_screen_mode.lock().unwrap() = enabled;
348        }
349    }
350
351    fn uses_main_screen(&self) -> bool {
352        self.main_screen_mode
353            .lock()
354            .map(|mode| *mode)
355            .unwrap_or(false)
356    }
357
358    /// Set the layout root component.
359    pub fn set_layout_root(&self, component: Option<Arc<dyn Component>>) {
360        if let Ok(mut root) = self.layout_root.lock() {
361            *root = component;
362        }
363        self.request_render(false);
364    }
365
366    /// Get the layout root.
367    pub fn get_layout_root(&self) -> Option<Arc<dyn Component>> {
368        self.layout_root.lock().ok()?.clone()
369    }
370
371    fn is_running(&self) -> bool {
372        self.running.lock().map(|running| *running).unwrap_or(false)
373    }
374
375    /// Temporarily suspend the outer application renderer while a foreign
376    /// runtime owns the terminal (for example a Node extension's fullscreen
377    /// component). The terminal remains in raw/alternate-screen mode; only
378    /// background repaint requests are suppressed.
379    pub fn set_render_suspended(&self, suspended: bool) {
380        if let Ok(mut value) = self.render_suspended.lock() {
381            *value = suspended;
382        }
383        if !suspended {
384            self.request_render(true);
385        }
386    }
387
388    pub fn is_render_suspended(&self) -> bool {
389        self.render_suspended
390            .lock()
391            .map(|value| *value)
392            .unwrap_or(false)
393    }
394
395    /// Get the current scroll position.
396    pub fn viewport_top(&self) -> usize {
397        if let Some(scroll) = self.get_primary_scroll_view() {
398            return scroll.scroll_top();
399        }
400        self.scroll_top.lock().map(|s| *s).unwrap_or(0)
401    }
402
403    /// Check if following output.
404    pub fn is_following_output(&self) -> bool {
405        if let Some(scroll) = self.get_primary_scroll_view() {
406            return scroll.is_following_end();
407        }
408        self.stick_to_bottom.lock().map(|s| *s).unwrap_or(true)
409    }
410
411    /// Set a clickable label shown while the follow-end primary view is scrolled up.
412    /// The label is recomputed for each frame, allowing live theme and key hints.
413    pub fn set_scroll_to_end_indicator(
414        &self,
415        label: Option<Arc<dyn Fn() -> String + Send + Sync>>,
416    ) {
417        self.jump_to_end.set_label(label);
418        if self.is_running() {
419            self.request_render_reusing_scroll_content();
420        }
421    }
422
423    /// Handle a click before the host starts text selection or other mouse actions.
424    pub fn handle_scroll_to_end_indicator_mouse_event(
425        &self,
426        event: &crossterm::event::MouseEvent,
427    ) -> bool {
428        if self.uses_main_screen()
429            || self.is_render_suspended()
430            || self.has_overlay()
431            || !self.jump_to_end.hit(event)
432        {
433            return false;
434        }
435        let Some(scroll) = self.get_primary_scroll_view() else {
436            return false;
437        };
438        if scroll.is_following_end() || !scroll.follows_end() {
439            return false;
440        }
441        self.scroll_to_bottom();
442        true
443    }
444
445    /// Scroll by the given number of lines.
446    pub fn scroll_by(&self, lines: i32) {
447        if let Some(scroll) = self.get_primary_scroll_view() {
448            scroll.scroll_by(lines);
449            self.request_render_reusing_scroll_content();
450            return;
451        }
452        if let Ok(mut scroll_top) = self.scroll_top.lock() {
453            if lines > 0 {
454                *scroll_top = scroll_top.saturating_add(lines as usize);
455            } else {
456                *scroll_top = scroll_top.saturating_sub((-lines) as usize);
457            }
458        }
459        if lines != 0 {
460            if let Ok(mut stick) = self.stick_to_bottom.lock() {
461                *stick = lines > 0; // Re-enable stick on scroll down
462            }
463        }
464        self.request_render(false);
465    }
466
467    /// Scroll to the top.
468    pub fn scroll_to_top(&self) {
469        if let Some(scroll) = self.get_primary_scroll_view() {
470            scroll.scroll_to_start();
471            self.request_render_reusing_scroll_content();
472            return;
473        }
474        if let Ok(mut scroll_top) = self.scroll_top.lock() {
475            *scroll_top = 0;
476        }
477        if let Ok(mut stick) = self.stick_to_bottom.lock() {
478            *stick = false;
479        }
480        self.request_render(false);
481    }
482
483    /// Scroll to the bottom.
484    pub fn scroll_to_bottom(&self) {
485        if let Some(scroll) = self.get_primary_scroll_view() {
486            scroll.scroll_to_end();
487            self.request_render_reusing_scroll_content();
488            return;
489        }
490        if let Ok(mut stick) = self.stick_to_bottom.lock() {
491            *stick = true;
492        }
493        self.request_render(false);
494    }
495
496    /// Get the primary scroll view if any.
497    pub fn get_primary_scroll_view(&self) -> Option<Arc<ScrollView>> {
498        self.current_frame
499            .lock()
500            .ok()?
501            .as_ref()?
502            .primary_scroll_view
503            .clone()
504    }
505
506    /// Get the current terminal column count (cached, refreshed on resize).
507    /// Used by callers that need a width for off-layout rendering (e.g.
508    /// diff-line width sizing) without re-reading the terminal themselves.
509    pub fn width(&self) -> usize {
510        self.terminal.lock().map(|t| t.columns()).unwrap_or(80)
511    }
512
513    /// Set the terminal window/tab title.
514    ///
515    /// The [`Terminal`] trait's `set_title` on the real `ProcessTerminal` emits
516    /// `\x1b]2;{title}\x07` (OSC 2); the `DummyTerminal` stub is a no-op. This
517    /// accessor locks the real underlying terminal (bypassing the no-op trait
518    /// impl returned by [`TUI::terminal`]) so hosts can reflect run state in
519    /// the window title — e.g. "rpi — working" while a turn is in flight.
520    pub fn set_title(&self, title: &str) {
521        if let Ok(terminal) = self.terminal.lock() {
522            terminal.set_title(title);
523            terminal.flush();
524        }
525    }
526
527    /// Check if this implements ViewportTUI.
528    pub fn is_viewport_tui(&self) -> bool {
529        true
530    }
531
532    /// Enter alternate screen mode.
533    /// Refresh the cached terminal size (call on `Event::Resize`) and force a
534    /// full redraw so the constrained layout re-fits the new dimensions.
535    pub fn refresh_size(&self) {
536        if let Ok(terminal) = self.terminal.lock() {
537            terminal.refresh_size();
538        }
539        // Alt-screen frames need every viewport row repainted. Main-screen mode
540        // keeps the previous document so its renderer can detect width changes
541        // and deliberately rebuild terminal scrollback once.
542        if !self.uses_main_screen() {
543            if let Ok(mut prev) = self.previous_screen.lock() {
544                prev.clear();
545            }
546        }
547        self.request_render(false);
548    }
549
550    fn enter_alt_screen(&self) {
551        if let Ok(terminal) = self.terminal.lock() {
552            // Enter alternate screen buffer and disable autowrap
553            terminal.write("\x1b[?1049h\x1b[?7l");
554            // Clear screen and hide cursor
555            terminal.write("\x1b[2J\x1b[H\x1b[?25l");
556            terminal.flush();
557        }
558    }
559
560    /// Exit alternate screen mode.
561    fn exit_alt_screen(&self, preserve_screen: bool) {
562        if let Ok(terminal) = self.terminal.lock() {
563            // Autowrap is a terminal mode, not restored by leaving the buffer.
564            terminal.write("\x1b[?7h");
565            if preserve_screen {
566                terminal.write("\x1b[?1049l\x1b[?25h");
567            } else {
568                // Exit the alt buffer CLEANLY: back to the main buffer, clear
569                // it, and show the cursor. The old path re-rendered the full
570                // TUI frame (borders, backgrounds, spinner state, cursor
571                // markers) into the main buffer, which is exactly the garbage
572                // that made quitting look scrambled. The caller prints its own
573                // farewell after this.
574                terminal.write("\x1b[?1049l\x1b[2J\x1b[H\x1b[?25h");
575            }
576            terminal.flush();
577        }
578    }
579
580    /// Hand the terminal back to the shell so the process can be stopped
581    /// (upstream's `handleCtrlZ`): pause the renderer, leave the alternate
582    /// buffer, and restore cooked mode. [`TuiAltScreen::resume`] reverses it
583    /// once `SIGTSTP` returns.
584    ///
585    /// Unlike the private `stop`, this keeps the render scheduler alive, so the
586    /// same TUI can be resumed in place.
587    pub fn suspend(&self) {
588        self.set_render_suspended(true);
589        if let Ok(terminal) = self.terminal.lock() {
590            terminal.disable_mouse();
591            terminal.exit_raw_mode();
592            terminal.flush();
593        }
594        if !self.uses_main_screen() {
595            // `preserve_screen`: leave the alt buffer without clearing the
596            // user's main-buffer scrollback (unlike a normal shutdown).
597            self.exit_alt_screen(true);
598        }
599    }
600
601    /// Re-enter raw mode and the alternate buffer after the process resumes
602    /// from `SIGTSTP`, then force a full repaint (the alt buffer came back
603    /// empty — `set_render_suspended` is what schedules it).
604    pub fn resume(&self) {
605        if !self.uses_main_screen() {
606            self.enter_alt_screen();
607        }
608        if let Ok(terminal) = self.terminal.lock() {
609            terminal.enter_raw_mode();
610            if self.uses_main_screen() {
611                terminal.disable_mouse();
612            } else {
613                terminal.enable_mouse();
614            }
615            terminal.flush();
616        }
617        self.set_render_suspended(false);
618    }
619
620    /// Render the complete component tree into the main terminal buffer. This
621    /// follows upstream's regular-mode strategy: append growth with CRLF so
622    /// the terminal creates real scrollback, and rewrite only the changed tail.
623    fn do_render_main_screen(&self) {
624        let Ok(_render_guard) = self.render_lock.lock() else {
625            return;
626        };
627        // A render request may have passed the public suspension check while
628        // a custom UI was opening. Re-check after taking the render lock so an
629        // already queued outer frame cannot overwrite the foreign screen.
630        if self.is_render_suspended() {
631            return;
632        }
633        let Ok(terminal) = self.terminal.lock() else {
634            return;
635        };
636        let width = terminal.columns();
637        let height = terminal.rows();
638        let root: Arc<dyn Component> = self
639            .get_layout_root()
640            .unwrap_or_else(|| Arc::new(self.container.clone()));
641        let raw_lines = root.render(width);
642        let cursor = raw_lines.iter().enumerate().rev().find_map(|(row, line)| {
643            line.find(CURSOR_MARKER)
644                .map(|idx| (row, visible_width(&line[..idx])))
645        });
646        let lines: Vec<String> = raw_lines
647            .into_iter()
648            .map(|line| line.replace(CURSOR_MARKER, ""))
649            .collect();
650        let previous = self
651            .previous_screen
652            .lock()
653            .map(|p| p.clone())
654            .unwrap_or_default();
655        let previous_width = *self.main_previous_width.lock().unwrap();
656        let previous_height = *self.main_previous_height.lock().unwrap();
657        let previous_cursor = *self.main_previous_cursor.lock().unwrap();
658        terminal.write(&crate::terminal_image::removed_kitty_placements(
659            &previous, &lines,
660        ));
661
662        // Size changes alter wrapping or viewport coordinates everywhere;
663        // mirror upstream and rebuild once. Normal streaming updates stay
664        // incremental and preserve terminal scrollback.
665        if (previous_width != 0 && previous_width != width)
666            || (previous_height != 0 && previous_height != height)
667        {
668            terminal.write("\x1b[2J\x1b[H\x1b[3J");
669            terminal.write("\x1b[?2026h");
670            terminal.write(&lines.join("\r\n"));
671            terminal.write("\x1b[?2026l");
672            *self.main_hardware_row.lock().unwrap() = lines.len().saturating_sub(1);
673            *self.main_viewport_top.lock().unwrap() = lines.len().saturating_sub(height);
674        } else if previous.is_empty() {
675            terminal.write("\x1b[?2026h");
676            terminal.write(&lines.join("\r\n"));
677            terminal.write("\x1b[?2026l");
678            *self.main_hardware_row.lock().unwrap() = lines.len().saturating_sub(1);
679            *self.main_viewport_top.lock().unwrap() = lines.len().saturating_sub(height);
680        } else {
681            let mut first_changed = None;
682            let mut last_changed = None;
683            let max = previous.len().max(lines.len());
684            for index in 0..max {
685                if previous.get(index) != lines.get(index) {
686                    first_changed.get_or_insert(index);
687                    last_changed = Some(index);
688                }
689            }
690            if first_changed.is_none() && cursor == previous_cursor {
691                return;
692            }
693            if let (Some(first), Some(last)) = (first_changed, last_changed) {
694                let mut hardware_row = *self.main_hardware_row.lock().unwrap();
695                let mut viewport_top = *self.main_viewport_top.lock().unwrap();
696                let viewport_bottom = viewport_top + height.saturating_sub(1);
697                let deleted_tail_only = first >= lines.len();
698                let append_start = first == previous.len() && first > 0;
699                let move_target = if deleted_tail_only {
700                    lines.len().saturating_sub(1)
701                } else if append_start {
702                    first - 1
703                } else {
704                    first
705                };
706                let mut output = String::from("\x1b[?2026h");
707                let mut force_tail_repaint = false;
708                if move_target < viewport_top {
709                    // The changed range starts above the visible viewport (e.g.
710                    // a long streamed message re-wrapping above the visible
711                    // tail). Scroll the viewport up so the write lands on the
712                    // correct document rows; without this the cursor clamps to
713                    // the top row and the transcript is painted over the dock,
714                    // while `advanced` under-counts the real scroll and corrupts
715                    // `main_viewport_top`/`main_hardware_row` for later frames.
716                    //
717                    // SD leaves the rows below the rewritten range blank, so
718                    // when the rewrite does NOT reach the document tail (an
719                    // in-place edit above the viewport) those rows must still be
720                    // repainted — force the write range through the last line.
721                    force_tail_repaint = true;
722                    let scroll = viewport_top - move_target;
723                    output.push_str("\x1b[H");
724                    output.push_str(&format!("\x1b[{scroll}T"));
725                    viewport_top = move_target;
726                    hardware_row = move_target;
727                } else if move_target > viewport_bottom {
728                    let current_screen = hardware_row
729                        .saturating_sub(viewport_top)
730                        .min(height.saturating_sub(1));
731                    let down = height.saturating_sub(1).saturating_sub(current_screen);
732                    if down > 0 {
733                        output.push_str(&format!("\x1b[{down}B"));
734                    }
735                    let scroll = move_target - viewport_bottom;
736                    output.push_str(&"\r\n".repeat(scroll));
737                    viewport_top += scroll;
738                    hardware_row = move_target;
739                }
740                let current_screen = hardware_row.saturating_sub(viewport_top);
741                let target_screen = move_target.saturating_sub(viewport_top);
742                if target_screen > current_screen {
743                    output.push_str(&format!("\x1b[{}B", target_screen - current_screen));
744                } else if current_screen > target_screen {
745                    output.push_str(&format!("\x1b[{}A", current_screen - target_screen));
746                }
747                output.push_str(if append_start { "\r\n" } else { "\r" });
748                let mut render_end = last.min(lines.len().saturating_sub(1));
749                if force_tail_repaint {
750                    render_end = lines.len().saturating_sub(1);
751                }
752                if !deleted_tail_only {
753                    for index in first..=render_end {
754                        if index > first {
755                            output.push_str("\r\n");
756                        }
757                        if let Some((column, text)) = previous
758                            .get(index)
759                            .and_then(|old| crate::line_diff::patch(old, &lines[index]))
760                        {
761                            if column > 0 {
762                                output.push_str(&format!("\x1b[{column}C"));
763                            }
764                            output.push_str(&text);
765                        } else {
766                            output.push_str(&lines[index]);
767                            if index < previous.len()
768                                && visible_width(&lines[index]) < visible_width(&previous[index])
769                            {
770                                output.push_str("\x1b[K");
771                            }
772                        }
773                    }
774                }
775                let mut final_row = render_end;
776                if previous.len() > lines.len() {
777                    let extra_lines = previous.len() - lines.len();
778                    let clear_start_offset = usize::from(!lines.is_empty());
779                    if clear_start_offset > 0 {
780                        output.push_str("\x1b[1B");
781                    }
782                    for index in 0..extra_lines {
783                        output.push_str("\r\x1b[2K");
784                        if index + 1 < extra_lines {
785                            output.push_str("\x1b[1B");
786                        }
787                    }
788                    let move_back = extra_lines.saturating_sub(1) + clear_start_offset;
789                    if move_back > 0 {
790                        output.push_str(&format!("\x1b[{move_back}A"));
791                    }
792                    if extra_lines > 0 {
793                        final_row = lines.len().saturating_sub(1);
794                    }
795                }
796                output.push_str("\x1b[?2026l");
797                terminal.write(&output);
798                let advanced = final_row.saturating_sub(viewport_top + height.saturating_sub(1));
799                viewport_top += advanced;
800                *self.main_hardware_row.lock().unwrap() = final_row;
801                *self.main_viewport_top.lock().unwrap() = viewport_top;
802            }
803        }
804
805        if let Some((row, col)) = cursor {
806            let hardware_row = *self.main_hardware_row.lock().unwrap();
807            let mut viewport_top = *self.main_viewport_top.lock().unwrap();
808            let viewport_bottom = viewport_top + height.saturating_sub(1);
809
810            if row > viewport_bottom {
811                // The editor row is below the visible window (a write scrolled
812                // up to an edit above the old viewport). Scroll back down so
813                // the editor is the bottom row, then move the cursor there.
814                let scroll = row - viewport_bottom;
815                let mut output = String::new();
816                let current_screen = hardware_row
817                    .saturating_sub(viewport_top)
818                    .min(height.saturating_sub(1));
819                let down = height.saturating_sub(1).saturating_sub(current_screen);
820                if down > 0 {
821                    output.push_str(&format!("\x1b[{down}B"));
822                }
823                output.push_str(&"\r\n".repeat(scroll));
824                terminal.write(&output);
825                viewport_top += scroll;
826            } else if row < viewport_top {
827                // The editor row is above the visible window. Scroll up so it
828                // becomes the top row, then repaint the revealed viewport — SD
829                // leaves the scrolled-off bottom rows blank otherwise.
830                let scroll = viewport_top - row;
831                terminal.write(&format!("\x1b[H\x1b[{scroll}T"));
832                viewport_top = row;
833                let mut output = String::from("\x1b[?2026h");
834                for i in 0..height {
835                    let line = lines
836                        .get(viewport_top + i)
837                        .map(String::as_str)
838                        .unwrap_or("");
839                    if i > 0 {
840                        output.push_str("\r\n");
841                    }
842                    output.push_str("\x1b[2K");
843                    output.push_str(line);
844                }
845                // Return the cursor to the top row (the editor row) so the
846                // column move below lands on it.
847                output.push_str(&format!("\x1b[{}A", height.saturating_sub(1)));
848                output.push_str("\x1b[?2026l");
849                terminal.write(&output);
850            } else {
851                // Within the window: a relative move is enough (and never
852                // scrolls, so it cannot drift the viewport bookkeeping).
853                let current_screen = hardware_row
854                    .saturating_sub(viewport_top)
855                    .min(height.saturating_sub(1));
856                let target_screen = row - viewport_top;
857                if target_screen > current_screen {
858                    terminal.write(&format!("\x1b[{}B", target_screen - current_screen));
859                } else if current_screen > target_screen {
860                    terminal.write(&format!("\x1b[{}A", current_screen - target_screen));
861                }
862            }
863
864            terminal.write(&format!("\r\x1b[{}C", col));
865            terminal.write("\x1b[?25h");
866            *self.main_hardware_row.lock().unwrap() = row;
867            *self.main_viewport_top.lock().unwrap() = viewport_top;
868        } else {
869            terminal.write("\x1b[?25l");
870        }
871        terminal.flush();
872        *self.previous_screen.lock().unwrap() = lines;
873        *self.main_previous_width.lock().unwrap() = width;
874        *self.main_previous_height.lock().unwrap() = height;
875        *self.main_previous_cursor.lock().unwrap() = cursor;
876    }
877
878    /// Perform a differential render with constrained layout.
879    fn do_render(&self, reuse_scroll_content: bool) {
880        if self.is_render_suspended() {
881            return;
882        }
883        // upstream stamps the frame time immediately before `doRender()`; the
884        // scheduler reads it to hold the next request back for the remainder of
885        // the throttle window.
886        self.mark_frame_painted();
887        if self.uses_main_screen() {
888            self.do_render_main_screen();
889            return;
890        }
891        let Ok(_render_guard) = self.render_lock.lock() else {
892            return;
893        };
894        if self.is_render_suspended() {
895            return;
896        }
897
898        let (width, height) = if let Ok(terminal) = self.terminal.lock() {
899            (terminal.columns(), terminal.rows())
900        } else {
901            return;
902        };
903
904        // Get layout root or use container
905        let root = self.get_layout_root();
906        let root_component: Arc<dyn Component> =
907            root.unwrap_or_else(|| Arc::new(self.container.clone()));
908
909        // Render layout frame
910        let frame = if reuse_scroll_content {
911            render_layout_frame_reusing_scroll_content(root_component.clone(), width, height)
912        } else {
913            render_layout_frame(root_component.clone(), width, height)
914        };
915
916        // Store the frame for input handling
917        if let Ok(mut current) = self.current_frame.lock() {
918            *current = Some(frame.clone());
919        }
920
921        // Get screen lines and composite modal overlays above the layout.
922        let mut visible = frame.lines.clone();
923        self.jump_to_end
924            .paint(&frame, &mut visible, self.has_overlay());
925        self.paint_overlays(&mut visible, width, height);
926
927        // Cursor markers are an internal layout protocol, not terminal output.
928        // Extract the location first, then strip every marker before diffing or
929        // writing. Sending the APC marker to some Windows terminals caused the
930        // character under the cursor to be erased when moving left/right.
931        let cursor = extract_cursor_position(&visible, height);
932        for line in &mut visible {
933            *line = line.replace(CURSOR_MARKER, "");
934        }
935
936        // Check for full redraw
937        let previous = self
938            .previous_screen
939            .lock()
940            .map(|p| p.clone())
941            .unwrap_or_default();
942        let full_redraw = previous.len() != height || previous.is_empty();
943
944        // Build output buffer
945        let mut buffer = String::new();
946        buffer.push_str(&crate::terminal_image::removed_kitty_placements(
947            &previous, &visible,
948        ));
949
950        if let Ok(terminal) = self.terminal.lock() {
951            if full_redraw {
952                if let Ok(mut count) = self.full_redraw_count.lock() {
953                    *count += 1;
954                }
955                buffer.push_str("\x1b[2J"); // Clear screen
956            }
957
958            // Begin synchronized output
959            buffer.push_str("\x1b[?2026h");
960
961            // Write changed lines
962            for (row, line) in visible.iter().enumerate() {
963                if !full_redraw && row < previous.len() && &previous[row] == line {
964                    continue; // Skip unchanged lines
965                }
966                // Overwrite before clearing any leftover tail. Clearing the
967                // whole row first blanks stationary sidebars during scroll on
968                // terminals that do not implement synchronized output.
969                if let Some((column, text)) = (!full_redraw)
970                    .then(|| crate::line_diff::patch(&previous[row], line))
971                    .flatten()
972                {
973                    buffer.push_str(&format!("\x1b[{};{}H{}", row + 1, column + 1, text));
974                } else {
975                    buffer.push_str(&format!("\x1b[{};1H{}", row + 1, line));
976                    if !full_redraw && visible_width(line) < visible_width(&previous[row]) {
977                        buffer.push_str("\x1b[K");
978                    }
979                }
980            }
981
982            // Position the hardware cursor using the marker location captured
983            // before internal markers were stripped from `visible`.
984            if let Some((row, col)) = cursor {
985                buffer.push_str(&format!("\x1b[{};{}H", row + 1, col + 1));
986                if self.get_show_hardware_cursor() {
987                    buffer.push_str("\x1b[?25h");
988                }
989            } else {
990                // Hide cursor if no cursor marker found
991                buffer.push_str("\x1b[?25l");
992            }
993
994            // End synchronized output
995            buffer.push_str("\x1b[?2026l");
996
997            terminal.write(&buffer);
998            terminal.flush();
999        }
1000
1001        // Store current screen
1002        if let Ok(mut prev) = self.previous_screen.lock() {
1003            *prev = visible;
1004        }
1005    }
1006
1007    fn paint_overlays(&self, lines: &mut [String], width: usize, height: usize) {
1008        for overlay in self.overlays.render_visible(width, height) {
1009            let (x, y, overlay_width) = (overlay.x, overlay.y, overlay.width);
1010            let overlay_lines = overlay.lines;
1011            let overlay_height = overlay_lines.len();
1012            for (index, line) in overlay_lines.iter().take(overlay_height).enumerate() {
1013                let row = y + index;
1014                if row >= lines.len() {
1015                    break;
1016                }
1017                lines[row] = composite_tui_line(&lines[row], line, x, overlay_width, width);
1018            }
1019        }
1020    }
1021}
1022
1023impl Component for TuiAltScreen {
1024    fn render(&self, width: usize) -> Vec<String> {
1025        self.get_layout_root()
1026            .map(|r| r.render(width))
1027            .unwrap_or_else(|| self.container.render(width))
1028    }
1029
1030    fn invalidate(&self) {
1031        if let Some(root) = self.get_layout_root() {
1032            root.invalidate();
1033        } else {
1034            self.container.invalidate();
1035        }
1036    }
1037
1038    fn as_any(&self) -> &dyn Any {
1039        self
1040    }
1041}
1042
1043impl TUI for TuiAltScreen {
1044    fn mode(&self) -> TuiMode {
1045        if self.uses_main_screen() {
1046            TuiMode::Regular
1047        } else {
1048            TuiMode::Fullscreen
1049        }
1050    }
1051
1052    fn terminal(&self) -> &dyn Terminal {
1053        &self.terminal_proxy
1054    }
1055
1056    fn children(&self) -> Vec<Arc<dyn Component>> {
1057        self.container.get_children()
1058    }
1059
1060    fn add_child(&self, component: Arc<dyn Component>) {
1061        self.container.add_child(component);
1062    }
1063
1064    fn remove_child(&self, component: &Arc<dyn Component>) {
1065        self.container.remove_child(component);
1066    }
1067
1068    fn clear(&self) {
1069        self.container.clear();
1070    }
1071
1072    fn get_show_hardware_cursor(&self) -> bool {
1073        self.show_hardware_cursor
1074            .lock()
1075            .map(|s| *s)
1076            .unwrap_or(false)
1077    }
1078
1079    fn set_show_hardware_cursor(&self, enabled: bool) {
1080        if let Ok(mut show) = self.show_hardware_cursor.lock() {
1081            *show = enabled;
1082        }
1083    }
1084
1085    fn get_clear_on_shrink(&self) -> bool {
1086        self.clear_on_shrink.lock().map(|s| *s).unwrap_or(false)
1087    }
1088
1089    fn set_clear_on_shrink(&self, enabled: bool) {
1090        if let Ok(mut clear) = self.clear_on_shrink.lock() {
1091            *clear = enabled;
1092        }
1093    }
1094
1095    fn set_focus(&self, component: Option<Arc<dyn Component>>) {
1096        if let Ok(mut focused) = self.focused.lock() {
1097            *focused = component;
1098        }
1099    }
1100
1101    fn get_focus(&self) -> Option<Arc<dyn Component>> {
1102        self.focused.lock().ok()?.clone()
1103    }
1104
1105    fn show_overlay(
1106        &self,
1107        component: Arc<dyn Component>,
1108        options: Option<OverlayOptions>,
1109    ) -> Arc<dyn OverlayHandle> {
1110        let handle = self.overlays.add(component, options.unwrap_or_default());
1111        self.request_render(false);
1112        Arc::new(ManagedOverlayHandle {
1113            handle,
1114            hidden: Mutex::new(false),
1115            focused: Mutex::new(true),
1116        })
1117    }
1118
1119    fn hide_overlay(&self) {
1120        self.overlays.remove_topmost();
1121        self.request_render(false);
1122    }
1123
1124    fn has_overlay(&self) -> bool {
1125        !self.overlays.get_visible().is_empty()
1126    }
1127
1128    fn start(&self) {
1129        if let Ok(mut running) = self.running.lock() {
1130            *running = true;
1131        }
1132
1133        // Clone the necessary Arc references for the closures
1134        let running = self.running.clone();
1135
1136        // Keyboard protocol stacks belong to each screen buffer. Switch first
1137        // so the terminal enables its protocol on the screen that receives input.
1138        if !self.uses_main_screen() {
1139            self.enter_alt_screen();
1140        }
1141
1142        // Get terminal and start it (spawns its own input-reader thread whose
1143        // callbacks are stubs below; kept for the non-pi-tui callers that still
1144        // rely on `start()`). The asynchronous interactive path uses
1145        // `start_readerless` instead to avoid a competing stdin reader.
1146        if let Ok(terminal) = self.terminal.lock() {
1147            terminal.start(
1148                Box::new(move |event: InputEvent| {
1149                    if !*running.lock().unwrap() {
1150                        return;
1151                    }
1152                    let _ = event; // stub: input handled by the caller's own loop
1153                }),
1154                Box::new(move || {}),
1155            );
1156            if self.uses_main_screen() {
1157                terminal.disable_mouse();
1158            } else {
1159                terminal.enable_mouse();
1160            }
1161            terminal.flush();
1162        }
1163
1164        self.do_render(false);
1165    }
1166
1167    fn stop(&self, options: TuiStopOptions) {
1168        if let Ok(mut running) = self.running.lock() {
1169            *running = false;
1170        }
1171
1172        // Stop the coalescing scheduler *before* the terminal goes away, and
1173        // join it so a frame already in flight cannot repaint the screen after
1174        // shutdown (the class of bug the 0.1.18 regular-mode fix targets).
1175        self.scheduler.shutdown();
1176        if let Some(handle) = self.scheduler_thread.lock().ok().and_then(|mut s| s.take()) {
1177            let _ = handle.join();
1178        }
1179
1180        if self.uses_main_screen() {
1181            if let Ok(terminal) = self.terminal.lock() {
1182                // Leave the shell prompt below the rendered footer while
1183                // preserving all conversation rows in terminal scrollback.
1184                terminal.write("\x1b[?25h\r\n");
1185                terminal.stop();
1186            }
1187        } else {
1188            // Pop the keyboard protocol on its owning screen before leaving it.
1189            if let Ok(terminal) = self.terminal.lock() {
1190                terminal.stop();
1191            }
1192            self.exit_alt_screen(options.preserve_screen);
1193        }
1194    }
1195
1196    fn render_now(&self, force: bool) {
1197        // Layout is assembled before `start()`. Rendering during that phase
1198        // writes the future TUI frame into the main screen, which is then
1199        // restored on exit and appears as uncleared output.
1200        if !self.is_running() {
1201            return;
1202        }
1203        if self.is_render_suspended() {
1204            return;
1205        }
1206        if force {
1207            if let Ok(mut prev) = self.previous_screen.lock() {
1208                prev.clear();
1209            }
1210        }
1211        // An immediate frame supersedes a parked coalesced one: upstream's
1212        // `renderNow` clears `renderRequested` and cancels the render timer.
1213        self.scheduler.clear_pending();
1214        self.do_render(false);
1215    }
1216
1217    fn request_render(&self, force: bool) {
1218        if self.is_render_suspended() {
1219            return;
1220        }
1221        if force {
1222            // upstream `requestRender(true)`: reset the diff state and paint an
1223            // immediate frame instead of waiting out the throttle window.
1224            self.render_now(true);
1225            return;
1226        }
1227        if !self.is_running() {
1228            return;
1229        }
1230        // Coalesced (upstream `requestRender(false)`): park the frame and let
1231        // the scheduler paint it at most once per `MIN_RENDER_INTERVAL_MS`.
1232        self.park_render(false);
1233    }
1234
1235    fn full_redraws(&self) -> usize {
1236        self.full_redraw_count.lock().map(|c| *c).unwrap_or(0)
1237    }
1238}
1239
1240impl TuiAltScreen {
1241    /// Set up the tty and render the first frame **without** spawning the
1242    /// `ProcessTerminal` input-reader thread. The caller owns the input loop
1243    /// (e.g. a `spawn_blocking` `event::read()` loop) and handles resize/key
1244    /// events directly via [`TuiAltScreen::refresh_size`] / its key dispatch.
1245    ///
1246    /// The first frame is painted inline. Hosts that want the native-pi frame
1247    /// throttle should also call [`TuiAltScreen::start_render_scheduler`].
1248    ///
1249    /// This avoids two `event::read()` consumers racing the same stdin queue
1250    /// (the stub thread in [`TUI::start`] dropped a fraction of keystrokes).
1251    /// The caller is responsible for `enable_raw_mode`-dependent event reads
1252    /// and for calling `refresh_size` on `Event::Resize`.
1253    pub fn start_readerless(&self) {
1254        if let Ok(mut running) = self.running.lock() {
1255            *running = true;
1256        }
1257        if !self.uses_main_screen() {
1258            self.enter_alt_screen();
1259        }
1260        if let Ok(terminal) = self.terminal.lock() {
1261            terminal.enter_raw_mode();
1262            if self.uses_main_screen() {
1263                // Give wheel/touchpad gestures back to the terminal emulator;
1264                // it can now scroll its native history smoothly.
1265                terminal.disable_mouse();
1266            } else {
1267                // On macOS enter_raw_mode intentionally leaves mouse tracking
1268                // disabled so the main screen can use native scrollback. The
1269                // alternate-screen transcript owns scrolling, however, so it
1270                // must explicitly re-enable wheel/touchpad events here.
1271                terminal.enable_mouse();
1272            }
1273            terminal.flush();
1274        }
1275        self.do_render(false);
1276    }
1277
1278    /// Render a frame while reusing scroll-view content. This is appropriate
1279    /// when only the viewport or bottom dock changed. A missing cache or width
1280    /// change falls back to rendering fresh content automatically.
1281    ///
1282    /// Coalesced like [`TUI::request_render`]; a request that upgrades the parked
1283    /// frame to a full transcript render wins.
1284    pub fn request_render_reusing_scroll_content(&self) {
1285        if !self.is_running() || self.is_render_suspended() {
1286            return;
1287        }
1288        self.park_render(true);
1289    }
1290}
1291
1292/// Check if a TUI implements ViewportTUI.
1293pub fn is_viewport_tui(_tui: &dyn TUI) -> bool {
1294    _tui.as_any().is::<TuiAltScreen>()
1295}
1296
1297struct ManagedOverlayHandle {
1298    handle: super::overlay::OverlayHandle,
1299    hidden: Mutex<bool>,
1300    focused: Mutex<bool>,
1301}
1302
1303impl OverlayHandle for ManagedOverlayHandle {
1304    fn hide(&self) {
1305        self.handle.hide();
1306        if let Ok(mut hidden) = self.hidden.lock() {
1307            *hidden = true;
1308        }
1309    }
1310
1311    fn set_hidden(&self, hidden: bool) {
1312        self.handle.set_visible(!hidden);
1313        if let Ok(mut current) = self.hidden.lock() {
1314            *current = hidden;
1315        }
1316    }
1317
1318    fn is_hidden(&self) -> bool {
1319        self.hidden.lock().map(|hidden| *hidden).unwrap_or(true)
1320    }
1321
1322    fn focus(&self) {
1323        if let Ok(mut focused) = self.focused.lock() {
1324            *focused = true;
1325        }
1326    }
1327
1328    fn is_focused(&self) -> bool {
1329        self.focused.lock().map(|focused| *focused).unwrap_or(false)
1330    }
1331}
1332
1333/// Shared terminal proxy exposed through [`TUI::terminal`]. The TUI owns the
1334/// terminal behind a mutex because render and input paths can run concurrently,
1335/// while callers of the trait need a stable `&dyn Terminal` reference.
1336struct TerminalProxy {
1337    terminal: Arc<Mutex<Box<dyn Terminal>>>,
1338}
1339
1340impl TerminalProxy {
1341    fn with<R>(&self, f: impl FnOnce(&dyn Terminal) -> R) -> Option<R> {
1342        self.terminal
1343            .lock()
1344            .ok()
1345            .map(|terminal| f(terminal.as_ref()))
1346    }
1347}
1348
1349impl Terminal for TerminalProxy {
1350    fn info(&self) -> TerminalInfo {
1351        self.with(|terminal| terminal.info()).unwrap_or_default()
1352    }
1353
1354    fn write(&self, data: &str) {
1355        let _ = self.with(|terminal| terminal.write(data));
1356    }
1357
1358    fn hide_cursor(&self) {
1359        let _ = self.with(|terminal| terminal.hide_cursor());
1360    }
1361
1362    fn show_cursor(&self) {
1363        let _ = self.with(|terminal| terminal.show_cursor());
1364    }
1365
1366    fn move_cursor(&self, row: usize, col: usize) {
1367        let _ = self.with(|terminal| terminal.move_cursor(row, col));
1368    }
1369
1370    fn clear_screen(&self) {
1371        let _ = self.with(|terminal| terminal.clear_screen());
1372    }
1373
1374    fn set_title(&self, title: &str) {
1375        let _ = self.with(|terminal| terminal.set_title(title));
1376    }
1377
1378    fn enable_mouse(&self) {
1379        let _ = self.with(|terminal| terminal.enable_mouse());
1380    }
1381
1382    fn disable_mouse(&self) {
1383        let _ = self.with(|terminal| terminal.disable_mouse());
1384    }
1385
1386    fn enter_raw_mode(&self) {
1387        let _ = self.with(|terminal| terminal.enter_raw_mode());
1388    }
1389
1390    fn exit_raw_mode(&self) {
1391        let _ = self.with(|terminal| terminal.exit_raw_mode());
1392    }
1393
1394    fn refresh_size(&self) {
1395        let _ = self.with(|terminal| terminal.refresh_size());
1396    }
1397
1398    fn start(
1399        &self,
1400        on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
1401        on_resize: Box<dyn Fn() + Send + Sync>,
1402    ) {
1403        let _ = self.with(|terminal| terminal.start(on_input, on_resize));
1404    }
1405
1406    fn stop(&self) {
1407        let _ = self.with(|terminal| terminal.stop());
1408    }
1409
1410    fn is_tty(&self) -> bool {
1411        self.with(|terminal| terminal.is_tty()).unwrap_or(false)
1412    }
1413
1414    fn set_progress(&self, active: bool) {
1415        let _ = self.with(|terminal| terminal.set_progress(active));
1416    }
1417
1418    fn flush(&self) {
1419        let _ = self.with(|terminal| terminal.flush());
1420    }
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425    use super::*;
1426    use crate::{Editor, Focusable, Text};
1427    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1428
1429    #[test]
1430    fn changed_rows_overwrite_without_blanking_sidebar_and_clear_shorter_tails_afterwards() {
1431        for main_screen in [false, true] {
1432            let output = Arc::new(Mutex::new(String::new()));
1433            let tui = TuiAltScreen::new(
1434                Box::new(RecordingTerminal {
1435                    output: output.clone(),
1436                }),
1437                false,
1438                None,
1439            );
1440            let text = Arc::new(Text::new("chat one    │ RUN STATS", 0, 0));
1441            tui.set_main_screen_mode(main_screen);
1442            tui.set_layout_root(Some(text.clone()));
1443            tui.start_readerless();
1444            output.lock().unwrap().clear();
1445            text.set_text("chat two    │ RUN STATS");
1446            tui.do_render(false);
1447            let frame = output.lock().unwrap().clone();
1448            assert!(frame.contains("tw"));
1449            assert!(!frame.contains("RUN STATS"));
1450            assert!(!frame.contains("\x1b[2K"));
1451            assert!(!frame.contains("\x1b[2J"));
1452            output.lock().unwrap().clear();
1453            text.set_text("short");
1454            tui.do_render(false);
1455            let frame = output.lock().unwrap().clone();
1456            assert!(frame.contains("short"));
1457            assert!(!frame.contains("\x1b[2K"));
1458        }
1459    }
1460
1461    #[test]
1462    fn jump_click_resumes_following_and_preserves_the_editor_draft_and_focus() {
1463        use crate::{StackChild, StackEntry, VStack};
1464        use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
1465        let tui = TuiAltScreen::new(
1466            Box::new(RecordingTerminal {
1467                output: Arc::new(Mutex::new(String::new())),
1468            }),
1469            false,
1470            None,
1471        );
1472        let text = Arc::new(Text::new(
1473            (1..=20)
1474                .map(|n| format!("message {n}"))
1475                .collect::<Vec<_>>()
1476                .join("\n"),
1477            0,
1478            0,
1479        ));
1480        let scroll = Arc::new(ScrollView::primary(text.clone()));
1481        let editor = Arc::new(Editor::simple());
1482        editor.set_text("未发送的草稿");
1483        let root = Arc::new(VStack::from_children(vec![
1484            StackChild::Entry(StackEntry::new(scroll.clone()).basis(0).grow(1).min_size(1)),
1485            StackChild::Entry(StackEntry::new(editor.clone())),
1486        ]));
1487        tui.set_layout_root(Some(root));
1488        tui.set_focus(Some(editor.clone()));
1489        tui.set_scroll_to_end_indicator(Some(Arc::new(|| {
1490            " ↓ Jump to latest message · End ".to_owned()
1491        })));
1492        tui.start_readerless();
1493        tui.scroll_by(-4);
1494        assert!(!scroll.is_following_end());
1495        assert!(tui
1496            .previous_screen
1497            .lock()
1498            .unwrap()
1499            .iter()
1500            .any(|line| line.contains("Jump to latest")));
1501        let click = MouseEvent {
1502            kind: MouseEventKind::Down(MouseButton::Left),
1503            column: 20,
1504            row: (scroll.viewport_height() - 1) as u16,
1505            modifiers: KeyModifiers::NONE,
1506        };
1507        assert!(tui.handle_scroll_to_end_indicator_mouse_event(&click));
1508        assert!(scroll.is_following_end());
1509        assert!(!tui
1510            .previous_screen
1511            .lock()
1512            .unwrap()
1513            .iter()
1514            .any(|line| line.contains("Jump to latest")));
1515        assert_eq!(editor.get_text(), "未发送的草稿");
1516        let focused: Arc<dyn Component> = editor.clone();
1517        assert!(Arc::ptr_eq(&tui.get_focus().unwrap(), &focused));
1518        text.set_text(
1519            (1..=24)
1520                .map(|n| format!("message {n}"))
1521                .collect::<Vec<_>>()
1522                .join("\n"),
1523        );
1524        tui.do_render(false);
1525        assert_eq!(
1526            scroll.scroll_top(),
1527            scroll.content_height() - scroll.viewport_height()
1528        );
1529        assert!(tui
1530            .previous_screen
1531            .lock()
1532            .unwrap()
1533            .iter()
1534            .any(|line| line.contains("message 24")));
1535        tui.stop(Default::default());
1536    }
1537
1538    struct RecordingTerminal {
1539        output: Arc<Mutex<String>>,
1540    }
1541
1542    impl Terminal for RecordingTerminal {
1543        fn info(&self) -> TerminalInfo {
1544            TerminalInfo {
1545                columns: 40,
1546                rows: 8,
1547                ..Default::default()
1548            }
1549        }
1550
1551        fn write(&self, data: &str) {
1552            self.output.lock().unwrap().push_str(data);
1553        }
1554
1555        fn hide_cursor(&self) {}
1556        fn show_cursor(&self) {}
1557        fn move_cursor(&self, _row: usize, _col: usize) {}
1558        fn clear_screen(&self) {}
1559        fn set_title(&self, _title: &str) {}
1560        fn enable_mouse(&self) {}
1561        fn disable_mouse(&self) {}
1562        fn enter_raw_mode(&self) {
1563            self.write("<raw-on>");
1564        }
1565        fn exit_raw_mode(&self) {
1566            self.write("<raw-off>");
1567        }
1568        fn refresh_size(&self) {}
1569        fn start(
1570            &self,
1571            _on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
1572            _on_resize: Box<dyn Fn() + Send + Sync>,
1573        ) {
1574            self.enter_raw_mode();
1575        }
1576        fn stop(&self) {
1577            self.write("<stop>");
1578        }
1579        fn is_tty(&self) -> bool {
1580            true
1581        }
1582        fn set_progress(&self, _active: bool) {}
1583        fn flush(&self) {}
1584    }
1585
1586    #[test]
1587    fn keyboard_setup_and_teardown_stay_on_the_owning_screen_buffer() {
1588        for readerless in [true, false] {
1589            let output = Arc::new(Mutex::new(String::new()));
1590            let tui = TuiAltScreen::new(
1591                Box::new(RecordingTerminal {
1592                    output: output.clone(),
1593                }),
1594                true,
1595                None,
1596            );
1597            if readerless {
1598                tui.start_readerless();
1599            } else {
1600                tui.start();
1601            }
1602            let rendered = output.lock().unwrap().clone();
1603            assert!(rendered.find("\x1b[?1049h").unwrap() < rendered.find("<raw-on>").unwrap());
1604            output.lock().unwrap().clear();
1605            tui.suspend();
1606            let rendered = output.lock().unwrap().clone();
1607            assert!(rendered.find("<raw-off>").unwrap() < rendered.find("\x1b[?1049l").unwrap());
1608            assert!(rendered.contains("\x1b[?7h"));
1609            output.lock().unwrap().clear();
1610            tui.resume();
1611            let rendered = output.lock().unwrap().clone();
1612            assert!(rendered.find("\x1b[?1049h").unwrap() < rendered.find("<raw-on>").unwrap());
1613            output.lock().unwrap().clear();
1614            tui.stop(TuiStopOptions::default());
1615            let rendered = output.lock().unwrap().clone();
1616            assert!(rendered.find("<stop>").unwrap() < rendered.find("\x1b[?1049l").unwrap());
1617        }
1618    }
1619
1620    #[test]
1621    fn regular_start_never_switches_to_the_alternate_screen() {
1622        let output = Arc::new(Mutex::new(String::new()));
1623        let tui = TuiAltScreen::new(
1624            Box::new(RecordingTerminal {
1625                output: output.clone(),
1626            }),
1627            true,
1628            None,
1629        );
1630        tui.set_main_screen_mode(true);
1631        tui.start();
1632        tui.suspend();
1633        tui.resume();
1634        tui.stop(TuiStopOptions::default());
1635        let rendered = output.lock().unwrap().clone();
1636        assert!(rendered.contains("<raw-on>"));
1637        assert!(!rendered.contains("\x1b[?1049"));
1638    }
1639
1640    #[test]
1641    fn cursor_marker_is_never_written_to_the_terminal() {
1642        let output = Arc::new(Mutex::new(String::new()));
1643        let terminal = RecordingTerminal {
1644            output: output.clone(),
1645        };
1646        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1647        let editor = Arc::new(Editor::simple());
1648        editor.set_focused(true);
1649        editor.insert("hello");
1650        tui.set_layout_root(Some(editor.clone()));
1651        tui.start_readerless();
1652
1653        editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1654        tui.request_render(false);
1655
1656        let rendered = output.lock().unwrap().clone();
1657        assert!(!rendered.contains(CURSOR_MARKER));
1658        assert!(rendered.contains("hello"));
1659        assert_eq!(editor.get_text(), "hello");
1660    }
1661
1662    #[test]
1663    fn layout_does_not_touch_main_screen_and_stop_clears_it() {
1664        let output = Arc::new(Mutex::new(String::new()));
1665        let terminal = RecordingTerminal {
1666            output: output.clone(),
1667        };
1668        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1669
1670        tui.set_layout_root(Some(Arc::new(Text::new("screen content", 0, 0))));
1671        assert!(output.lock().unwrap().is_empty());
1672
1673        tui.start_readerless();
1674        assert!(output.lock().unwrap().contains("screen content"));
1675
1676        tui.stop(TuiStopOptions::default());
1677        let rendered = output.lock().unwrap().clone();
1678        let content_position = rendered.find("screen content").unwrap();
1679        let clear_position = rendered
1680            .rfind("\x1b[?1049l\x1b[2J\x1b[H\x1b[?25h")
1681            .expect("stop should leave alt screen and clear the restored main screen");
1682        assert!(clear_position > content_position);
1683    }
1684
1685    #[test]
1686    fn cached_redraw_is_suppressed_while_rendering_is_suspended() {
1687        let output = Arc::new(Mutex::new(String::new()));
1688        let terminal = RecordingTerminal {
1689            output: output.clone(),
1690        };
1691        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1692        tui.set_layout_root(Some(Arc::new(Text::new("outer frame", 0, 0))));
1693        tui.start_readerless();
1694
1695        tui.set_render_suspended(true);
1696        let before = output.lock().unwrap().len();
1697        tui.request_render_reusing_scroll_content();
1698        assert_eq!(output.lock().unwrap().len(), before);
1699
1700        tui.set_render_suspended(false);
1701        tui.set_layout_root(Some(Arc::new(Text::new("new frame", 0, 0))));
1702        assert!(output.lock().unwrap().len() > before);
1703    }
1704
1705    #[test]
1706    fn regular_single_line_update_does_not_rewrite_unchanged_tail() {
1707        let output = Arc::new(Mutex::new(String::new()));
1708        let terminal = RecordingTerminal {
1709            output: output.clone(),
1710        };
1711        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1712        let content = Arc::new(Text::new("header\nstatus\neditor\nfooter", 0, 0));
1713        tui.set_main_screen_mode(true);
1714        tui.set_layout_root(Some(content.clone()));
1715        tui.start_readerless();
1716        output.lock().unwrap().clear();
1717
1718        content.set_text("header\nstatus2\neditor\nfooter");
1719        tui.request_render(false);
1720
1721        let rendered = output.lock().unwrap().clone();
1722        assert!(rendered.contains("\x1b[6C2"));
1723        assert!(!rendered.contains("editor"));
1724        assert!(!rendered.contains("footer"));
1725    }
1726
1727    #[test]
1728    fn regular_unchanged_frame_writes_nothing() {
1729        let output = Arc::new(Mutex::new(String::new()));
1730        let terminal = RecordingTerminal {
1731            output: output.clone(),
1732        };
1733        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1734        tui.set_main_screen_mode(true);
1735        tui.set_layout_root(Some(Arc::new(Text::new(
1736            "header\nstatus\neditor\nfooter",
1737            0,
1738            0,
1739        ))));
1740        tui.start_readerless();
1741        output.lock().unwrap().clear();
1742
1743        tui.request_render(false);
1744
1745        assert!(output.lock().unwrap().is_empty());
1746    }
1747
1748    #[test]
1749    fn merge_pending_render_never_downgrades_a_full_render() {
1750        // Nothing parked: the request's own variant wins.
1751        assert_eq!(merge_pending_render(None, false), Some(false));
1752        assert_eq!(merge_pending_render(None, true), Some(true));
1753        // A parked cached-content frame is upgraded by a full render...
1754        assert_eq!(merge_pending_render(Some(true), false), Some(false));
1755        // ...and stays cached when another cached request arrives.
1756        assert_eq!(merge_pending_render(Some(true), true), Some(true));
1757        // A parked full render is never downgraded by a cached request.
1758        assert_eq!(merge_pending_render(Some(false), true), Some(false));
1759        assert_eq!(merge_pending_render(Some(false), false), Some(false));
1760    }
1761
1762    #[test]
1763    fn rapid_requests_coalesce_into_a_single_frame() {
1764        let output = Arc::new(Mutex::new(String::new()));
1765        let terminal = RecordingTerminal {
1766            output: output.clone(),
1767        };
1768        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1769        let content = Arc::new(Text::new("first", 0, 0));
1770        tui.set_layout_root(Some(content.clone()));
1771        tui.enable_render_coalescing();
1772        tui.start_readerless();
1773        output.lock().unwrap().clear();
1774
1775        // A streaming burst: many requests, no painting until the host flush.
1776        content.set_text("second");
1777        for _ in 0..50 {
1778            tui.request_render(false);
1779        }
1780        assert!(
1781            output.lock().unwrap().is_empty(),
1782            "parked requests must not repaint the transcript"
1783        );
1784
1785        // The host flush paints exactly one frame and clears what was owed.
1786        assert!(tui.flush_pending_render());
1787        let rendered = output.lock().unwrap().clone();
1788        assert!(rendered.contains("second"));
1789        assert!(!tui.flush_pending_render(), "nothing owed after the flush");
1790    }
1791
1792    #[test]
1793    fn immediate_render_discards_a_parked_frame() {
1794        let output = Arc::new(Mutex::new(String::new()));
1795        let terminal = RecordingTerminal {
1796            output: output.clone(),
1797        };
1798        let tui = TuiAltScreen::new(Box::new(terminal), true, None);
1799        let content = Arc::new(Text::new("before", 0, 0));
1800        tui.set_layout_root(Some(content.clone()));
1801        tui.enable_render_coalescing();
1802        tui.start_readerless();
1803        output.lock().unwrap().clear();
1804
1805        content.set_text("after");
1806        tui.request_render(false);
1807        // An immediate frame (keyboard input, selector open) wins over the
1808        // parked one instead of rendering the same content twice.
1809        tui.render_now(true);
1810
1811        assert!(output.lock().unwrap().contains("after"));
1812        assert!(!tui.flush_pending_render());
1813    }
1814
1815    /// End-to-end throttle check on the real scheduler thread. Each frame emits
1816    /// exactly one synchronized-output opener (`\x1b[?2026h`), so counting those
1817    /// counts painted frames. Before the throttle, every one of the 200
1818    /// "streamed deltas" repainted the whole transcript.
1819    #[test]
1820    fn streaming_burst_paints_at_most_one_frame_per_interval() {
1821        let output = Arc::new(Mutex::new(String::new()));
1822        let terminal = RecordingTerminal {
1823            output: output.clone(),
1824        };
1825        let tui = Arc::new(TuiAltScreen::new(Box::new(terminal), true, None));
1826        let content = Arc::new(Text::new("0", 0, 0));
1827        tui.set_layout_root(Some(content.clone()));
1828        tui.start_render_scheduler();
1829        tui.start_readerless();
1830        output.lock().unwrap().clear();
1831
1832        for i in 0..200 {
1833            content.set_text(&format!("delta {i}"));
1834            tui.request_render(false);
1835            std::thread::sleep(Duration::from_millis(1));
1836        }
1837        std::thread::sleep(Duration::from_millis(50));
1838
1839        let frames = output.lock().unwrap().matches("\x1b[?2026h").count();
1840        assert!(frames >= 1, "the scheduler must still paint frames");
1841        assert!(
1842            frames <= 40,
1843            "200 requests in ~250 ms must coalesce into ~16 ms frames, painted {frames}"
1844        );
1845        tui.stop(TuiStopOptions::default());
1846    }
1847}