Skip to main content

whisker_cli/
tui.rs

1//! Inline-viewport TUI for `whisker run`.
2//!
3//! ## Design (vs. the alternate-screen design in #187)
4//!
5//! Full-screen ratatui owned the terminal and erased scrollback;
6//! cargo / gradle / xcodebuild log bursts during a build no longer
7//! fit in any reasonable pane and the user couldn't scroll back
8//! through them. The codex-rs TUI solves this by anchoring a small
9//! "live region" at the bottom and pushing everything else into the
10//! terminal's *normal* scrollback via ANSI scroll-region tricks. We
11//! get the same shape for free out of ratatui 0.29's
12//! [`Viewport::Inline`] + [`Terminal::insert_before`] (with the
13//! `scrolling-regions` feature enabled so we land on the DECSTBM
14//! fast path).
15//!
16//! Layout while the cli is running:
17//!
18//! ```text
19//! ── terminal scrollback (mouse-wheel scrollable) ──────────────────
20//!   …earlier shell output…
21//!   ▶ Setup
22//!   ✓ Sync gen/ios            124ms
23//!   ▶ Initial build
24//!   warning: unused import: `Foo`   ← captured cargo stderr
25//!   ✓ Initial build           6.2s
26//!   ▶ Install + launch
27//!   …
28//! ── live region (LIVE_HEIGHT rows, redraws ~10Hz) ─────────────────
29//!    whisker run · iOS Simulator · rs.example.bar · building · 4.1s
30//!    ⠋ xcodebuild …
31//!
32//!    q  quit
33//! ──────────────────────────────────────────────────────────────────
34//! ```
35//!
36//! ## Subprocess output → scrollback
37//!
38//! cargo / gradle / xcodebuild and every `whisker_build::ui::*` call
39//! write to stderr. We `dup2` `STDERR_FILENO` to a pipe whose read
40//! end a dedicated thread drains line-by-line, strips ANSI escapes
41//! from, and sends through an mpsc channel. The render thread drains
42//! that channel each frame and calls
43//! [`Terminal::insert_before`] per line, so captured output lands
44//! above the live region — which the terminal's scrollback keeps for
45//! us. ratatui's backend is wired to the *saved* original stderr fd
46//! so its own draw escapes don't self-loop into the pipe.
47//!
48//! Because stderr is no longer a TTY once we `dup2` it, cargo /
49//! gradle / xcodebuild automatically fall back to line-based output
50//! (no in-place progress bars), which is exactly what we want for
51//! scrollback.
52//!
53//! ## State machine
54//!
55//! [`AppPhase`] tracks where the dev loop is. The cli calls
56//! [`TuiHandle::set_phase`] for phases it drives directly
57//! (`Setup`, `Initializing`); dev-server events drive the rest via
58//! [`TuiHandle::apply_event`]. Each transition emits a one-line
59//! "▶ <phase>" / "✓ <phase>  Xs" history entry plus updates the live
60//! header.
61
62use anyhow::{Context, Result};
63use crossterm::{
64    cursor,
65    event::{poll, read, Event as CtEvent, KeyCode, KeyEventKind, KeyModifiers},
66    terminal::{disable_raw_mode, enable_raw_mode},
67    ExecutableCommand,
68};
69use ratatui::backend::CrosstermBackend;
70use ratatui::buffer::Buffer;
71use ratatui::layout::Rect;
72use ratatui::style::{Color, Modifier, Style};
73use ratatui::text::{Line, Span};
74use ratatui::widgets::{Paragraph, Widget, Wrap};
75use ratatui::{Terminal, TerminalOptions, Viewport};
76use std::io::Write;
77use std::os::raw::c_int;
78use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError};
79use std::sync::{Arc, Mutex};
80use std::time::{Duration, Instant};
81
82/// Height of the live region in rows. The header, current step,
83/// dev-server info and key hint together comfortably fit in 6 rows;
84/// taller cuts scrollback density and saves nothing.
85const LIVE_HEIGHT: u16 = 6;
86
87const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
88
89// ============================================================================
90// Public state model
91// ============================================================================
92
93/// Which phase of the dev loop the user is currently watching. Drives
94/// the live-region header label + spinner color.
95#[derive(Debug, Clone)]
96pub enum AppPhase {
97    /// Pre-dev-server cli work: `sync_for_target` (gen tree + plugin
98    /// build). Driven by explicit `TuiHandle::set_phase` calls.
99    Setup,
100    /// dev-server's setup (WS bind, watcher, capture shim resolve).
101    /// Brief; usually flips to `Building` within ~100ms once
102    /// `Event::BuildingFull` arrives.
103    Initializing,
104    /// `cargo` + `gradle` / `xcodebuild` in flight.
105    Building {
106        started_at: Instant,
107        kind: BuildKind,
108    },
109    /// dev-server bound, initial build succeeded, watching for source
110    /// changes.
111    Idle,
112    /// Tier 1 hot-patch in flight. Phase exit is signalled by
113    /// `Event::PatchSent` or a Tier 2 fallback's `Event::BuildingFull`.
114    Patching { started_at: Instant },
115    /// Build failed. The live region surfaces the cause and the cli
116    /// is about to exit non-zero.
117    Failed { phase: String, reason: String },
118}
119
120#[derive(Debug, Clone, Copy)]
121pub enum BuildKind {
122    Initial,
123    Rebuild,
124}
125
126/// Outcome of a completed step. Pushed to scrollback by
127/// [`TuiHandle::finish_step`]; never rendered in the live region.
128#[derive(Debug, Clone, Copy)]
129pub enum StepStatus {
130    Done,
131    Failed,
132    Skipped,
133}
134
135/// Snapshot the render thread reads on every frame to draw the live
136/// region. Mutated under a `Mutex` from the cli thread; everything
137/// that needs to *enter scrollback* goes through the history channel
138/// instead so the render thread can call `insert_before` from the
139/// thread that owns the ratatui terminal.
140#[derive(Debug, Clone)]
141pub struct LiveState {
142    pub target: String,
143    pub bundle: String,
144    pub phase: AppPhase,
145    /// Label of the in-progress step (e.g. "xcodebuild …"). Cleared
146    /// when the step finishes.
147    pub current_step: Option<String>,
148    pub ws_addr: Option<String>,
149    pub watching: Vec<String>,
150    pub client_count: usize,
151    pub last_build: Option<String>,
152    pub last_patch: Option<String>,
153    pub should_quit: bool,
154    /// `true` when the quit was triggered by a user keypress
155    /// (`q` / Esc / Ctrl-C), `false` when the cli called
156    /// `TuiHandle::request_quit` after its own work finished or
157    /// failed. The render thread uses this to decide whether to
158    /// force-exit the process after shutdown — the dev-server
159    /// `rt.block_on(server.run())` call in the main thread otherwise
160    /// blocks forever, so without a hard exit `q` would tear down the
161    /// TUI but leave the process running.
162    pub user_initiated_quit: bool,
163}
164
165impl LiveState {
166    pub fn new(target: impl Into<String>, bundle: impl Into<String>) -> Self {
167        Self {
168            target: target.into(),
169            bundle: bundle.into(),
170            phase: AppPhase::Setup,
171            current_step: None,
172            ws_addr: None,
173            watching: Vec::new(),
174            client_count: 0,
175            last_build: None,
176            last_patch: None,
177            should_quit: false,
178            user_initiated_quit: false,
179        }
180    }
181}
182
183/// One message the render thread receives from upstream producers
184/// (cli code, dev-server events, and the stderr capture thread).
185/// Most variants paint a row into the terminal's scrollback via
186/// [`Terminal::insert_before`]; the `SetCurrentStep` variant is the
187/// exception — it only mutates [`LiveState::current_step`] so the
188/// inline live region can show a spinner during the next frame.
189#[derive(Debug, Clone)]
190pub enum HistoryItem {
191    /// Phase-transition heading: "▶ Initial build".
192    PhaseEnter(String),
193    /// Phase-completion summary: "✓ Initial build  6.2s".
194    PhaseDone {
195        label: String,
196        status: StepStatus,
197        elapsed: Duration,
198    },
199    /// A completed step: "✓ Sync gen/ios       124ms".
200    Step {
201        label: String,
202        status: StepStatus,
203        elapsed: Duration,
204    },
205    /// One line captured from the dup2'd stderr pipe (with ANSI
206    /// escapes already stripped).
207    CapturedStderr(String),
208    /// Device log forwarded from the dev-server.
209    DeviceLog { stream: String, line: String },
210    /// One-shot failure description for the scrollback.
211    Failure(String),
212    /// Update the live region's `current_step` field. `Some(label)`
213    /// makes the spinner visible with the given label; `None`
214    /// hides it. Synthesised by the stderr capture thread when it
215    /// sees `whisker_build::ui::TUI_STEP_START_MARKER` /
216    /// `TUI_STEP_END_MARKER` — those markers let the dev-server's
217    /// `ui::step` calls drive the live spinner without committing
218    /// a "⏵ started" row that would later double-up with the
219    /// matching "✓ done" row in scrollback.
220    SetCurrentStep(Option<String>),
221}
222
223// ============================================================================
224// Event → state machine
225// ============================================================================
226
227/// Apply a dev-server event to the live state and emit any history
228/// entries the transition implies. Pure — the test suite exercises
229/// it without any terminal io.
230pub fn apply_event(
231    state: &mut LiveState,
232    event: &whisker_dev_server::Event,
233    history: &mut Vec<HistoryItem>,
234) {
235    use whisker_dev_server::Event;
236    match event {
237        Event::Started => {
238            // dev-server is up. The cli's explicit `set_phase` calls
239            // own the transition out of Initializing — respect that
240            // ordering so we don't race the "▶ Initial build" entry.
241        }
242        Event::BuildingFull => {
243            let kind = match state.phase {
244                AppPhase::Setup | AppPhase::Initializing => BuildKind::Initial,
245                _ => BuildKind::Rebuild,
246            };
247            state.phase = AppPhase::Building {
248                started_at: Instant::now(),
249                kind,
250            };
251            // Phase entry markers come from `whisker_build::ui::section`
252            // ("──── Initial build ────"), which lands in scrollback
253            // via the stderr capture. Emitting a second "▶ Initial
254            // build" line here would just duplicate it. Phase *exit*
255            // (the `✓ Initial build  6.2s` summary) is unique to our
256            // TUI and is emitted on `BuildSucceeded`.
257            state.current_step = None;
258        }
259        Event::BuildSucceeded => {
260            if let AppPhase::Building { started_at, kind } = &state.phase {
261                let elapsed = started_at.elapsed();
262                // Don't emit a `HistoryItem::PhaseDone` summary for
263                // builds. On iOS the `Event::BuildSucceeded` fires
264                // after `builder.build()` (= staging Swift sources,
265                // ~100ms); the *actual* cargo + xcodebuild work
266                // happens later inside `installer.install_and_launch`
267                // and gets its own `✓ xcodebuild Podcast … XX.Xs`
268                // row via `whisker_build::ui::step`. The Build
269                // section is already delimited by
270                // `──── Initial build ────` plus the in-line step
271                // rows, so an aggregate "✓ Initial build XXms"
272                // line either misleads (iOS) or duplicates the
273                // section header (Android / Host).
274                state.last_build = Some(format!(
275                    "{} · {}",
276                    if matches!(kind, BuildKind::Initial) {
277                        "initial"
278                    } else {
279                        "rebuild"
280                    },
281                    fmt_elapsed(elapsed)
282                ));
283            }
284            state.phase = AppPhase::Idle;
285            state.current_step = None;
286        }
287        Event::BuildFailed(msg) => {
288            let phase = "build".to_string();
289            history.push(HistoryItem::PhaseDone {
290                label: phase.clone(),
291                status: StepStatus::Failed,
292                elapsed: Duration::ZERO,
293            });
294            history.push(HistoryItem::Failure(msg.clone()));
295            state.phase = AppPhase::Failed {
296                phase,
297                reason: msg.clone(),
298            };
299            state.current_step = None;
300        }
301        Event::ClientConnected => {
302            state.client_count = state.client_count.saturating_add(1);
303        }
304        Event::ClientDisconnected => {
305            state.client_count = state.client_count.saturating_sub(1);
306        }
307        Event::PatchBuilding => {
308            // Flip into Patching the moment the dev-server starts
309            // assembling the hot patch (cargo + symbol-table diff
310            // + ASLR rebase, the wall-clock-heavy bit of the loop).
311            // `Event::PatchSent` flips back to Idle on completion;
312            // `Event::BuildingFull` covers the Tier 2 fallback case
313            // where Tier 1 errored out mid-build and the loop fell
314            // through to a cold rebuild — that event resets phase
315            // to Building, overriding this Patching state.
316            state.phase = AppPhase::Patching {
317                started_at: Instant::now(),
318            };
319        }
320        Event::PatchSent => {
321            if let AppPhase::Patching { started_at } = &state.phase {
322                let elapsed = started_at.elapsed();
323                state.last_patch = Some(fmt_elapsed(elapsed));
324            }
325            state.phase = AppPhase::Idle;
326            state.current_step = None;
327        }
328        Event::DeviceLog { stream, line, .. } => {
329            history.push(HistoryItem::DeviceLog {
330                stream: stream.clone(),
331                line: line.clone(),
332            });
333        }
334    }
335}
336
337// ============================================================================
338// TuiHandle: cli-side facade
339// ============================================================================
340
341/// Cheap-to-clone handle the cli code passes around to update the
342/// live region and to commit lines to scrollback. Thread-safe;
343/// non-blocking on send (a slow render thread can't stall the
344/// build).
345#[derive(Clone)]
346pub struct TuiHandle {
347    live: Arc<Mutex<LiveState>>,
348    tx: Sender<HistoryItem>,
349}
350
351impl TuiHandle {
352    fn with<F: FnOnce(&mut LiveState)>(&self, f: F) {
353        if let Ok(mut g) = self.live.lock() {
354            f(&mut g);
355        }
356    }
357    fn send(&self, item: HistoryItem) {
358        // Disconnected receiver is harmless — we just stop emitting.
359        let _ = self.tx.send(item);
360    }
361
362    /// Enter `phase`. Updates the live region's phase label/spinner
363    /// color and clears any in-progress step display. Does NOT push
364    /// a scrollback entry — `whisker_build::ui::section` already
365    /// prints labeled phase boundaries that flow into scrollback via
366    /// the stderr capture, so a duplicate "▶ <label>" line would
367    /// just be noise. Event-driven phase completions (build
368    /// finished, patch sent) still emit `HistoryItem::PhaseDone`
369    /// via [`apply_event`] — those carry elapsed-time data that
370    /// `whisker_build::ui` doesn't.
371    pub fn set_phase(&self, phase: AppPhase) {
372        self.with(|s| {
373            s.phase = phase;
374            s.current_step = None;
375        });
376    }
377
378    /// Begin a step. Updates `current_step` in the live region; on
379    /// `finish_step` the row gets committed to scrollback.
380    pub fn start_step(&self, label: impl Into<String>) {
381        let label = label.into();
382        self.with(|s| {
383            s.current_step = Some(label);
384        });
385    }
386
387    /// Finish the currently-displayed step. The row is pushed to
388    /// scrollback as "✓ <label>  <elapsed>"; the live region clears
389    /// `current_step`.
390    pub fn finish_step(&self, label: impl Into<String>, status: StepStatus, elapsed: Duration) {
391        let label = label.into();
392        self.with(|s| s.current_step = None);
393        self.send(HistoryItem::Step {
394            label,
395            status,
396            elapsed,
397        });
398    }
399
400    pub fn apply_event(&self, event: &whisker_dev_server::Event) {
401        let mut history: Vec<HistoryItem> = Vec::new();
402        self.with(|s| apply_event(s, event, &mut history));
403        for h in history {
404            self.send(h);
405        }
406    }
407
408    pub fn set_dev_server(&self, ws_addr: impl Into<String>, watching: Vec<String>) {
409        let ws_addr = ws_addr.into();
410        self.with(|s| {
411            s.ws_addr = Some(ws_addr);
412            s.watching = watching;
413        });
414    }
415
416    pub fn should_quit(&self) -> bool {
417        self.live.lock().map(|s| s.should_quit).unwrap_or(false)
418    }
419
420    pub fn request_quit(&self) {
421        self.with(|s| s.should_quit = true);
422    }
423
424    /// Test-only: pull a snapshot of the live state. Avoid in
425    /// production code — render is the only legitimate reader.
426    #[cfg(test)]
427    pub fn snapshot(&self) -> LiveState {
428        self.live.lock().unwrap().clone()
429    }
430}
431
432// ============================================================================
433// Tui: terminal owner + render loop
434// ============================================================================
435
436/// Writer for ratatui's crossterm backend. Writes to the *saved*
437/// (pre-dup2) stderr fd so the terminal's draw escapes don't loop
438/// back into the capture pipe.
439struct OriginalStderr(c_int);
440
441impl Write for OriginalStderr {
442    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
443        let n = unsafe { libc::write(self.0, buf.as_ptr() as *const _, buf.len()) };
444        if n < 0 {
445            Err(std::io::Error::last_os_error())
446        } else {
447            Ok(n as usize)
448        }
449    }
450    fn flush(&mut self) -> std::io::Result<()> {
451        Ok(())
452    }
453}
454
455pub struct Tui {
456    terminal: Terminal<CrosstermBackend<OriginalStderr>>,
457    live: Arc<Mutex<LiveState>>,
458    rx: Receiver<HistoryItem>,
459    saved_stderr_fd: c_int,
460    spinner_idx: usize,
461}
462
463impl Tui {
464    /// Set up the inline TUI, install the stderr capture, and hand
465    /// back a `(Tui, TuiHandle)` pair. The cli keeps `TuiHandle` and
466    /// passes `Tui` to a dedicated OS thread that runs the render
467    /// loop (ratatui's `Terminal` isn't `Send` once it has a backend
468    /// holding raw fds — keep it pinned to one thread).
469    pub fn start(target: String, bundle: String) -> Result<(Self, TuiHandle)> {
470        let (saved_stderr_fd, capture_read_fd) =
471            install_stderr_capture().context("install stderr capture")?;
472        install_terminal_cleanup_once(saved_stderr_fd);
473
474        enable_raw_mode().context("enable raw mode")?;
475        let mut original = OriginalStderr(saved_stderr_fd);
476        original.execute(cursor::Hide).context("hide cursor")?;
477
478        let backend = CrosstermBackend::new(original);
479        let terminal = Terminal::with_options(
480            backend,
481            TerminalOptions {
482                viewport: Viewport::Inline(LIVE_HEIGHT),
483            },
484        )
485        .context("create ratatui terminal (inline viewport)")?;
486
487        let live = Arc::new(Mutex::new(LiveState::new(target, bundle)));
488        let (tx, rx) = channel::<HistoryItem>();
489
490        {
491            // stderr capture → channel. Each captured line becomes
492            // a `HistoryItem::CapturedStderr` once we've stripped
493            // ANSI escape sequences.
494            let tx = tx.clone();
495            std::thread::Builder::new()
496                .name("whisker-tui-stderr-capture".into())
497                .spawn(move || capture_reader_loop(capture_read_fd, tx))
498                .context("spawn stderr capture reader")?;
499        }
500
501        let handle = TuiHandle {
502            live: Arc::clone(&live),
503            tx,
504        };
505
506        Ok((
507            Self {
508                terminal,
509                live,
510                rx,
511                saved_stderr_fd,
512                spinner_idx: 0,
513            },
514            handle,
515        ))
516    }
517
518    /// Drive the render loop until `should_quit` flips (either via
519    /// `q` / Esc / Ctrl-C in the terminal or via `TuiHandle::request_quit`
520    /// from the cli when its work finishes).
521    pub fn render_until_quit(&mut self) -> Result<()> {
522        let frame_interval = Duration::from_millis(100);
523        let mut last_draw = Instant::now() - frame_interval;
524        loop {
525            self.drain_history_into_scrollback()?;
526
527            if last_draw.elapsed() >= frame_interval {
528                self.spinner_idx = self.spinner_idx.wrapping_add(1);
529                let snapshot = self.live.lock().ok().map(|g| g.clone());
530                if let Some(s) = snapshot {
531                    let spinner_idx = self.spinner_idx;
532                    self.terminal
533                        .draw(|f| render_live(f, &s, spinner_idx))
534                        .context("draw live region")?;
535                }
536                last_draw = Instant::now();
537            }
538
539            // Short key poll — must be tight enough that pressing
540            // `q` feels responsive but not so tight it pegs a core.
541            // 50ms is the same budget the previous full-screen TUI
542            // used; works fine.
543            if poll(Duration::from_millis(50))? {
544                if let CtEvent::Key(key) = read()? {
545                    if matches!(key.kind, KeyEventKind::Press) {
546                        match key.code {
547                            KeyCode::Char('q') | KeyCode::Esc => self.user_quit(),
548                            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
549                                self.user_quit()
550                            }
551                            _ => {}
552                        }
553                    }
554                }
555            }
556
557            if let Ok(s) = self.live.lock() {
558                if s.should_quit {
559                    break;
560                }
561            }
562        }
563        Ok(())
564    }
565
566    fn drain_history_into_scrollback(&mut self) -> Result<()> {
567        loop {
568            match self.rx.try_recv() {
569                Ok(HistoryItem::SetCurrentStep(label)) => {
570                    // Live-region-only update: don't `insert_before`,
571                    // just mutate the shared `LiveState` so the next
572                    // frame picks up the new spinner label.
573                    if let Ok(mut s) = self.live.lock() {
574                        s.current_step = label;
575                    }
576                }
577                Ok(item) => {
578                    let lines = render_history_item(&item);
579                    let height = lines.len().min(u16::MAX as usize) as u16;
580                    if height == 0 {
581                        continue;
582                    }
583                    // Move `lines` into the closure — ratatui's `Buffer`
584                    // borrows the cells we copy from `Line`s.
585                    self.terminal
586                        .insert_before(height, move |buf| {
587                            write_lines_to_buffer(buf, &lines);
588                        })
589                        .context("insert history line into scrollback")?;
590                }
591                Err(TryRecvError::Empty) => break,
592                Err(TryRecvError::Disconnected) => break,
593            }
594        }
595        Ok(())
596    }
597
598    /// User-initiated quit (q / Esc / Ctrl-C from the TUI).
599    /// Distinguished from a cli-initiated quit (`TuiHandle::request_quit`)
600    /// so `run_until_quit`'s caller can decide to force-exit the
601    /// process — the dev-server's `rt.block_on` would otherwise keep
602    /// running after the TUI tears down.
603    fn user_quit(&self) {
604        if let Ok(mut s) = self.live.lock() {
605            s.should_quit = true;
606            s.user_initiated_quit = true;
607        }
608    }
609
610    /// Whether the most recent quit signal came from a user keypress
611    /// rather than a `TuiHandle::request_quit` call. Callers use this
612    /// after `render_until_quit` returns to decide whether to
613    /// `process::exit` (user quit while the dev-server was running) or
614    /// to fall through and let the cli's own return path run (cli
615    /// finished its work).
616    pub fn was_user_quit(&self) -> bool {
617        self.live
618            .lock()
619            .map(|s| s.user_initiated_quit)
620            .unwrap_or(false)
621    }
622
623    pub fn shutdown(mut self) -> Result<()> {
624        // One last drain so any final phase/Step/Failure entry the
625        // cli emitted between the last render and quit lands in
626        // scrollback before the live region disappears.
627        let _ = self.drain_history_into_scrollback();
628        // ratatui clears its viewport rows on `clear`; on Inline
629        // mode this leaves the scrollback intact and just blanks
630        // the LIVE_HEIGHT rows at the cursor. Then we restore the
631        // cursor via `Terminal::show_cursor` (not a bare crossterm
632        // `cursor::Show` against the saved fd) — going through the
633        // terminal clears its internal `hidden_cursor` flag, which
634        // otherwise causes ratatui's `Drop` impl to try the same
635        // call later when the fd is already closed and we'd see
636        // `Failed to show the cursor: Bad file descriptor` printed
637        // to the shell.
638        let _ = self.terminal.clear();
639        let _ = self.terminal.show_cursor();
640        // Force the cursor to column 0 of a fresh row before we
641        // hand the terminal back. `terminal.clear()` *should* leave
642        // the cursor at the viewport's top-left, but if the user
643        // hit `Ctrl-C` and the libc signal beat our `KeyEvent`
644        // handler to the punch, the in-flight render's last
645        // `\x1b[<row>;<col>H` is the position the shell's PS1
646        // inherits — and the visible symptom is a prompt that
647        // starts at column ~113 instead of 0. `\r` re-aligns,
648        // `\n` drops the prompt onto a brand-new row below the
649        // (now-blank) live region. Both bytes go through the saved
650        // stderr fd so they reach the real terminal even while
651        // STDERR_FILENO is still pointing at the capture pipe.
652        let mut original = OriginalStderr(self.saved_stderr_fd);
653        let _ = original.write_all(b"\r\n");
654        let _ = disable_raw_mode();
655        // Restore STDERR_FILENO to the saved fd so callers can
656        // continue to `eprintln!` after the TUI is gone; close the
657        // duplicated saved fd afterward. `Tui`'s drop order then
658        // unwinds the ratatui Terminal cleanly (hidden_cursor is
659        // already false, so Drop is a no-op).
660        unsafe {
661            libc::dup2(self.saved_stderr_fd, libc::STDERR_FILENO);
662            libc::close(self.saved_stderr_fd);
663        }
664        Ok(())
665    }
666}
667
668// ============================================================================
669// Stderr capture
670// ============================================================================
671
672fn install_stderr_capture() -> Result<(c_int, c_int)> {
673    let mut fds: [c_int; 2] = [-1, -1];
674    let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
675    if rc != 0 {
676        return Err(std::io::Error::last_os_error()).context("pipe(2)");
677    }
678    let read_fd = fds[0];
679    let write_fd = fds[1];
680    let saved_fd = unsafe { libc::dup(libc::STDERR_FILENO) };
681    if saved_fd == -1 {
682        let e = std::io::Error::last_os_error();
683        unsafe {
684            libc::close(read_fd);
685            libc::close(write_fd);
686        }
687        return Err(e).context("dup STDERR_FILENO");
688    }
689    if unsafe { libc::dup2(write_fd, libc::STDERR_FILENO) } == -1 {
690        let e = std::io::Error::last_os_error();
691        unsafe {
692            libc::close(read_fd);
693            libc::close(write_fd);
694            libc::close(saved_fd);
695        }
696        return Err(e).context("dup2 over STDERR_FILENO");
697    }
698    unsafe {
699        libc::close(write_fd);
700    }
701    Ok((saved_fd, read_fd))
702}
703
704fn capture_reader_loop(read_fd: c_int, tx: Sender<HistoryItem>) {
705    let mut buf = [0u8; 4096];
706    let mut partial: Vec<u8> = Vec::new();
707    loop {
708        let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
709        if n == -1 {
710            if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
711                continue;
712            }
713            return;
714        }
715        if n == 0 {
716            return;
717        }
718        let chunk = &buf[..n as usize];
719        partial.extend_from_slice(chunk);
720        while let Some(nl_pos) = partial.iter().position(|b| *b == b'\n') {
721            let mut line: Vec<u8> = partial.drain(..=nl_pos).collect();
722            while matches!(line.last(), Some(b'\n') | Some(b'\r')) {
723                line.pop();
724            }
725            let text = match String::from_utf8(line) {
726                Ok(s) => s,
727                Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(),
728            };
729            // Step markers are detected *before* `strip_ansi`, which
730            // drops C0 control bytes including the `\x1e` (RS) that
731            // frames our markers. The marker prefix is identical to
732            // `whisker_build::ui::TUI_STEP_*_MARKER`; we duplicate
733            // the constants as literals here to avoid pulling the
734            // `whisker-build` crate into a tighter ABI contract over
735            // an internal protocol.
736            if let Some(rest) = text.strip_prefix("\x1eWHISKER-TUI-STEP-START\x1e") {
737                let label = rest.replace('\x1e', " ").trim().to_string();
738                if !label.is_empty() && tx.send(HistoryItem::SetCurrentStep(Some(label))).is_err() {
739                    return;
740                }
741                continue;
742            }
743            if text == "\x1eWHISKER-TUI-STEP-END" {
744                if tx.send(HistoryItem::SetCurrentStep(None)).is_err() {
745                    return;
746                }
747                continue;
748            }
749            let text = strip_ansi(&text);
750            if !text.is_empty() && tx.send(HistoryItem::CapturedStderr(text)).is_err() {
751                return;
752            }
753        }
754    }
755}
756
757/// Strip ECMA-48 CSI (`\x1b[…<final>`) and OSC (`\x1b]…\x07` or
758/// `\x1b]…\x1b\\`) escapes from `s`. cargo / gradle write colored
759/// output via SGR (CSI ending in `m`); without this, the captured
760/// line would render as visible `^[[33mwarning…^[[0m` in the
761/// scrollback. Iterates over `chars()` so multi-byte UTF-8
762/// sequences (`whisker_build::ui` decorations like `▶ ✓ ·`) survive
763/// intact.
764fn strip_ansi(s: &str) -> String {
765    let mut out = String::with_capacity(s.len());
766    let mut iter = s.chars().peekable();
767    while let Some(c) = iter.next() {
768        if c == '\x1b' {
769            match iter.peek().copied() {
770                Some('[') => {
771                    iter.next();
772                    // Consume CSI parameter bytes until a final
773                    // byte in the 0x40..=0x7e range (the SGR
774                    // terminator `m` lives here).
775                    for ch in iter.by_ref() {
776                        if matches!(ch as u32, 0x40..=0x7e) {
777                            break;
778                        }
779                    }
780                }
781                Some(']') => {
782                    iter.next();
783                    // Consume the OSC string until BEL (`\x07`) or
784                    // ST (`ESC \`).
785                    while let Some(ch) = iter.next() {
786                        if ch == '\x07' {
787                            break;
788                        }
789                        if ch == '\x1b' {
790                            if matches!(iter.peek(), Some('\\')) {
791                                iter.next();
792                            }
793                            break;
794                        }
795                    }
796                }
797                _ => {
798                    // Lone ESC or unknown introducer — drop it.
799                }
800            }
801            continue;
802        }
803        // Drop other C0 control characters except tab. Multi-byte
804        // UTF-8 characters have a `u32` value ≥ 0x80, so they pass
805        // the `>= 0x20` check unconditionally.
806        if c == '\t' || (c as u32) >= 0x20 {
807            out.push(c);
808        }
809    }
810    out
811}
812
813// ============================================================================
814// Cleanup hooks
815// ============================================================================
816
817fn emergency_terminal_reset(original_stderr_fd: c_int) {
818    let mut o = OriginalStderr(original_stderr_fd);
819    let _ = o.execute(cursor::Show);
820    // Mirror `Tui::shutdown`'s cursor reset (see the comment
821    // there): without `\r\n` after `cursor::Show`, the shell
822    // prompt that takes over on `process::exit(130)` starts at
823    // whatever column the last live-region draw left the cursor.
824    let _ = o.write_all(b"\r\n");
825    let _ = disable_raw_mode();
826}
827
828fn install_terminal_cleanup_once(original_stderr_fd: c_int) {
829    use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
830    static INSTALLED: AtomicBool = AtomicBool::new(false);
831    static SAVED_FD: AtomicI32 = AtomicI32::new(-1);
832    SAVED_FD.store(original_stderr_fd, Ordering::Release);
833    if INSTALLED.swap(true, Ordering::AcqRel) {
834        return;
835    }
836    let prev_hook = std::panic::take_hook();
837    std::panic::set_hook(Box::new(move |info| {
838        emergency_terminal_reset(SAVED_FD.load(Ordering::Acquire));
839        prev_hook(info);
840    }));
841    let _ = ctrlc::set_handler(|| {
842        emergency_terminal_reset(SAVED_FD.load(Ordering::Acquire));
843        std::process::exit(130);
844    });
845}
846
847// ============================================================================
848// Rendering
849// ============================================================================
850
851fn render_live(frame: &mut ratatui::Frame, state: &LiveState, spinner_idx: usize) {
852    let area = frame.area();
853    let lines = build_live_lines(state, spinner_idx);
854    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
855}
856
857fn build_live_lines(state: &LiveState, spinner_idx: usize) -> Vec<Line<'static>> {
858    let mut lines: Vec<Line<'static>> = Vec::new();
859
860    // Header line: ` <STATUS>  <target> · <bundle> [· <elapsed>] `.
861    // The leading chip used to be a static ` whisker run ` brand
862    // mark; it now doubles as the phase indicator. Background color
863    // + label change with the dev loop's state, so the user reads
864    // the run's situation without parsing the trailing word.
865    let (chip_label, chip_bg, chip_fg) = status_chip(state);
866    let mut header: Vec<Span<'static>> = vec![
867        Span::styled(
868            format!(" {chip_label} "),
869            Style::default()
870                .fg(chip_fg)
871                .bg(chip_bg)
872                .add_modifier(Modifier::BOLD),
873        ),
874        Span::raw(" "),
875        Span::styled(
876            state.target.clone(),
877            Style::default().add_modifier(Modifier::BOLD),
878        ),
879        Span::styled(" · ", Style::default().fg(Color::DarkGray)),
880        Span::raw(state.bundle.clone()),
881    ];
882    if let Some(extra) = phase_elapsed(&state.phase) {
883        header.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
884        header.push(Span::styled(extra, Style::default().fg(Color::DarkGray)));
885    }
886    lines.push(Line::from(header));
887
888    // Current step line (spinner + label) OR phase-specific info.
889    match (&state.current_step, &state.phase) {
890        (Some(label), _) => {
891            let spinner = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()];
892            // Spinner colour = chip background so the eye reads the
893            // header + step row as one indicator. `chip_bg` is the
894            // RUNNING-or-BUILDING-or-PATCHING colour and is already
895            // computed for the header above; recompute here so the
896            // helper stays self-contained.
897            let (_, chip_bg, _) = status_chip(state);
898            lines.push(Line::from(vec![
899                Span::raw(" "),
900                Span::styled(spinner.to_string(), Style::default().fg(chip_bg)),
901                Span::raw("  "),
902                Span::raw(label.clone()),
903            ]));
904        }
905        (None, AppPhase::Failed { reason, .. }) => {
906            lines.push(Line::from(vec![
907                Span::raw(" "),
908                Span::styled(reason.clone(), Style::default().fg(Color::Red)),
909            ]));
910        }
911        (None, _) => {
912            lines.push(Line::from(""));
913        }
914    }
915
916    // Dev-server / clients info — shown once dev-server is bound,
917    // regardless of phase.
918    if let Some(addr) = &state.ws_addr {
919        lines.push(Line::from(vec![
920            Span::raw(" "),
921            Span::styled("dev server  ", Style::default().fg(Color::DarkGray)),
922            Span::raw(format!("ws://{addr}")),
923        ]));
924        let clients = format!("{} connected", state.client_count);
925        let mut watching = vec![
926            Span::raw(" "),
927            Span::styled("clients     ", Style::default().fg(Color::DarkGray)),
928            Span::raw(clients),
929        ];
930        if !state.watching.is_empty() {
931            watching.push(Span::styled(
932                "   ·   ",
933                Style::default().fg(Color::DarkGray),
934            ));
935            watching.push(Span::styled(
936                format!("watching {} path(s)", state.watching.len()),
937                Style::default().fg(Color::DarkGray),
938            ));
939        }
940        lines.push(Line::from(watching));
941    } else {
942        // Reserve one row so the layout doesn't jiggle when the
943        // dev-server comes online mid-build.
944        lines.push(Line::from(""));
945    }
946
947    // Spacer + footer hint. The key chip uses `White` (not `Black`)
948    // on the dark-gray background so it stays legible in
949    // dark-themed terminals where ANSI color 0 (`Color::Black`)
950    // resolves to the terminal's *background* hue and visually
951    // disappears against the chip's fill.
952    lines.push(Line::from(""));
953    lines.push(Line::from(vec![
954        Span::raw(" "),
955        Span::styled(
956            " q ",
957            Style::default()
958                .fg(Color::White)
959                .bg(Color::DarkGray)
960                .add_modifier(Modifier::BOLD),
961        ),
962        Span::styled("  quit", Style::default().fg(Color::DarkGray)),
963    ]));
964
965    // Truncate / pad to LIVE_HEIGHT so the viewport renders cleanly.
966    lines.truncate(LIVE_HEIGHT as usize);
967    while lines.len() < LIVE_HEIGHT as usize {
968        lines.push(Line::from(""));
969    }
970    lines
971}
972
973fn render_history_item(item: &HistoryItem) -> Vec<Line<'static>> {
974    match item {
975        HistoryItem::PhaseEnter(label) => vec![Line::from(vec![
976            Span::styled("▶ ", Style::default().fg(Color::Cyan)),
977            Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
978        ])],
979        HistoryItem::PhaseDone {
980            label,
981            status,
982            elapsed,
983        } => {
984            let (glyph, color) = match status {
985                StepStatus::Done => ("✓ ", Color::Green),
986                StepStatus::Failed => ("✗ ", Color::Red),
987                StepStatus::Skipped => ("○ ", Color::DarkGray),
988            };
989            vec![Line::from(vec![
990                Span::styled(glyph, Style::default().fg(color)),
991                Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
992                Span::raw("  "),
993                Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
994            ])]
995        }
996        HistoryItem::Step {
997            label,
998            status,
999            elapsed,
1000        } => {
1001            let (glyph, color) = match status {
1002                StepStatus::Done => ("✓", Color::Green),
1003                StepStatus::Failed => ("✗", Color::Red),
1004                StepStatus::Skipped => ("○", Color::DarkGray),
1005            };
1006            vec![Line::from(vec![
1007                Span::raw("  "),
1008                Span::styled(glyph, Style::default().fg(color)),
1009                Span::raw("  "),
1010                Span::raw(label.clone()),
1011                Span::raw("  "),
1012                Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
1013            ])]
1014        }
1015        HistoryItem::CapturedStderr(text) => {
1016            vec![Line::from(Span::raw(text.clone()))]
1017        }
1018        HistoryItem::DeviceLog { stream, line } => {
1019            let tag = match stream.as_str() {
1020                "stderr" => "[device:err]",
1021                _ => "[device]",
1022            };
1023            vec![Line::from(vec![
1024                Span::styled(tag, Style::default().fg(Color::Magenta)),
1025                Span::raw(" "),
1026                Span::raw(line.clone()),
1027            ])]
1028        }
1029        HistoryItem::Failure(reason) => vec![Line::from(vec![
1030            Span::styled(
1031                "✗ ",
1032                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1033            ),
1034            Span::styled(reason.clone(), Style::default().fg(Color::Red)),
1035        ])],
1036        HistoryItem::SetCurrentStep(_) => {
1037            // Live-region-only; consumed by
1038            // `drain_history_into_scrollback` before reaching here.
1039            Vec::new()
1040        }
1041    }
1042}
1043
1044/// Paint `lines` into `buf` starting at the buffer's top-left.
1045/// Used by `insert_before`'s draw_fn, which gives us a buffer that
1046/// is exactly the height we asked for and the terminal's full
1047/// width.
1048fn write_lines_to_buffer(buf: &mut Buffer, lines: &[Line<'static>]) {
1049    for (i, line) in lines.iter().enumerate() {
1050        let area = Rect {
1051            x: buf.area.x,
1052            y: buf.area.y + i as u16,
1053            width: buf.area.width,
1054            height: 1,
1055        };
1056        if area.y >= buf.area.bottom() {
1057            break;
1058        }
1059        Paragraph::new(line.clone()).render(area, buf);
1060    }
1061}
1062
1063/// Picks the leading status chip's (label, background, foreground)
1064/// triple for the current live state. Combines `LiveState::phase`
1065/// with `LiveState::current_step` so a long-running step that fires
1066/// AFTER `Event::BuildSucceeded` (e.g. `xcodebuild` inside
1067/// `installer.install_and_launch` — which runs while the phase is
1068/// already `Idle`) still surfaces as `BUILDING`. Once the launch
1069/// step finishes and `current_step` clears, the chip flips to
1070/// `RUNNING`. Order of checks is significant: `Failed` outranks
1071/// everything; `Patching` outranks both Building and Idle; in-flight
1072/// step outranks bare Idle.
1073fn status_chip(state: &LiveState) -> (&'static str, Color, Color) {
1074    if matches!(state.phase, AppPhase::Failed { .. }) {
1075        return ("FAILED", Color::Red, Color::White);
1076    }
1077    if matches!(state.phase, AppPhase::Patching { .. }) {
1078        return ("PATCHING", Color::Magenta, Color::Black);
1079    }
1080    if matches!(state.phase, AppPhase::Building { .. }) {
1081        return ("BUILDING", Color::Yellow, Color::Black);
1082    }
1083    // Idle: distinguish "actively doing install / launch work" (a
1084    // step is in flight, even though the phase-machine has moved on
1085    // from Building) from "truly settled and the app is live on the
1086    // device".
1087    if matches!(state.phase, AppPhase::Idle) {
1088        if state.current_step.is_some() {
1089            return ("BUILDING", Color::Yellow, Color::Black);
1090        }
1091        return ("RUNNING", Color::Green, Color::Black);
1092    }
1093    // Setup / Initializing — the very brief pre-build phases. Render
1094    // as a neutral chip so the user knows the loop is alive but not
1095    // doing anything heavy yet.
1096    ("STARTING", Color::DarkGray, Color::White)
1097}
1098
1099fn phase_elapsed(phase: &AppPhase) -> Option<String> {
1100    match phase {
1101        AppPhase::Building { started_at, .. } | AppPhase::Patching { started_at } => {
1102            Some(fmt_elapsed(started_at.elapsed()))
1103        }
1104        _ => None,
1105    }
1106}
1107
1108fn fmt_elapsed(d: Duration) -> String {
1109    let ms = d.as_millis();
1110    if ms < 1_000 {
1111        format!("{ms}ms")
1112    } else if ms < 60_000 {
1113        format!("{:.1}s", ms as f64 / 1_000.0)
1114    } else {
1115        let secs = ms / 1_000;
1116        format!("{}m{:02}s", secs / 60, secs % 60)
1117    }
1118}
1119
1120// ============================================================================
1121// Tests
1122// ============================================================================
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    use whisker_dev_server::Event;
1128
1129    fn s() -> LiveState {
1130        LiveState::new("iOS Simulator", "rs.whisker.podcast")
1131    }
1132
1133    fn drain(state: &mut LiveState, e: &Event) -> Vec<HistoryItem> {
1134        let mut h = Vec::new();
1135        apply_event(state, e, &mut h);
1136        h
1137    }
1138
1139    #[test]
1140    fn build_lifecycle_records_outcome() {
1141        let mut st = s();
1142        let started = drain(&mut st, &Event::BuildingFull);
1143        assert!(matches!(st.phase, AppPhase::Building { .. }));
1144        // BuildingFull no longer emits a PhaseEnter — that's
1145        // delegated to `whisker_build::ui::section` which lands in
1146        // scrollback via stderr capture.
1147        assert!(started.is_empty());
1148        let done = drain(&mut st, &Event::BuildSucceeded);
1149        assert!(matches!(st.phase, AppPhase::Idle));
1150        assert!(st.last_build.is_some());
1151        // BuildSucceeded no longer emits a `PhaseDone` summary —
1152        // the section header + per-step rows in scrollback are
1153        // enough. `last_build` is the only state mutation we care
1154        // about here, plus the phase transition above.
1155        assert!(done.is_empty());
1156    }
1157
1158    #[test]
1159    fn client_counter_saturates() {
1160        let mut st = s();
1161        drain(&mut st, &Event::ClientConnected);
1162        drain(&mut st, &Event::ClientConnected);
1163        assert_eq!(st.client_count, 2);
1164        drain(&mut st, &Event::ClientDisconnected);
1165        drain(&mut st, &Event::ClientDisconnected);
1166        drain(&mut st, &Event::ClientDisconnected);
1167        assert_eq!(st.client_count, 0);
1168    }
1169
1170    #[test]
1171    fn device_log_becomes_history_item() {
1172        let mut st = s();
1173        let h = drain(
1174            &mut st,
1175            &Event::DeviceLog {
1176                stream: "stdout".into(),
1177                line: "hello".into(),
1178                ts_micros: 0,
1179            },
1180        );
1181        assert_eq!(h.len(), 1);
1182        match &h[0] {
1183            HistoryItem::DeviceLog { stream, line } => {
1184                assert_eq!(stream, "stdout");
1185                assert_eq!(line, "hello");
1186            }
1187            other => panic!("expected DeviceLog, got {other:?}"),
1188        }
1189    }
1190
1191    #[test]
1192    fn patch_sent_records_elapsed_and_resets_phase() {
1193        let mut st = s();
1194        st.phase = AppPhase::Patching {
1195            started_at: Instant::now() - Duration::from_millis(615),
1196        };
1197        let h = drain(&mut st, &Event::PatchSent);
1198        assert!(matches!(st.phase, AppPhase::Idle));
1199        assert!(st.last_patch.is_some());
1200        // PhaseDone is no longer emitted on PatchSent — the dev-server
1201        // already emits `✓ patch tier 1 …` via `ui::step` and a
1202        // duplicate "Hot patch" summary row would just clutter
1203        // scrollback.
1204        assert!(h.is_empty());
1205    }
1206
1207    #[test]
1208    fn patch_building_transitions_phase_to_patching() {
1209        let mut st = s();
1210        st.phase = AppPhase::Idle;
1211        let h = drain(&mut st, &Event::PatchBuilding);
1212        assert!(
1213            matches!(st.phase, AppPhase::Patching { .. }),
1214            "phase should be Patching after PatchBuilding"
1215        );
1216        assert!(h.is_empty(), "PatchBuilding shouldn't emit history rows");
1217    }
1218
1219    #[test]
1220    fn build_failed_emits_failure_history() {
1221        let mut st = s();
1222        drain(&mut st, &Event::BuildingFull);
1223        let h = drain(&mut st, &Event::BuildFailed("link error".into()));
1224        assert!(matches!(st.phase, AppPhase::Failed { .. }));
1225        assert!(h.iter().any(|i| matches!(i, HistoryItem::Failure(_))));
1226    }
1227
1228    #[test]
1229    fn strip_ansi_removes_csi_sgr() {
1230        let s = "\x1b[33mwarning\x1b[0m: \x1b[1munused\x1b[0m";
1231        assert_eq!(strip_ansi(s), "warning: unused");
1232    }
1233
1234    #[test]
1235    fn strip_ansi_preserves_utf8_glyphs() {
1236        let s = "\x1b[32m✓\x1b[0m Sync gen/ios";
1237        assert_eq!(strip_ansi(s), "✓ Sync gen/ios");
1238    }
1239
1240    #[test]
1241    fn strip_ansi_drops_osc_titles() {
1242        let s = "\x1b]0;title\x07hello";
1243        assert_eq!(strip_ansi(s), "hello");
1244    }
1245
1246    #[test]
1247    fn build_live_lines_has_fixed_height() {
1248        let st = s();
1249        let lines = build_live_lines(&st, 0);
1250        assert_eq!(lines.len(), LIVE_HEIGHT as usize);
1251    }
1252
1253    #[test]
1254    fn build_live_lines_shows_current_step() {
1255        let mut st = s();
1256        st.current_step = Some("xcodebuild WhiskerDriver-Debug".into());
1257        let lines = build_live_lines(&st, 0);
1258        let rendered = lines
1259            .iter()
1260            .flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
1261            .collect::<Vec<_>>()
1262            .join("");
1263        assert!(rendered.contains("xcodebuild"));
1264    }
1265
1266    #[test]
1267    fn build_live_lines_shows_dev_server_when_set() {
1268        let mut st = s();
1269        st.ws_addr = Some("127.0.0.1:9090".into());
1270        st.client_count = 1;
1271        let lines = build_live_lines(&st, 0);
1272        let rendered = lines
1273            .iter()
1274            .flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
1275            .collect::<Vec<_>>()
1276            .join("");
1277        assert!(rendered.contains("127.0.0.1:9090"));
1278        assert!(rendered.contains("1 connected"));
1279    }
1280}