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 // SIGTERM any in-flight build before the hard-exit skips Drop,
844 // so Ctrl-C during a build doesn't orphan cargo / gradle /
845 // xcodebuild.
846 whisker_build::child_guard::kill_all();
847 std::process::exit(130);
848 });
849}
850
851// ============================================================================
852// Rendering
853// ============================================================================
854
855fn render_live(frame: &mut ratatui::Frame, state: &LiveState, spinner_idx: usize) {
856 let area = frame.area();
857 let lines = build_live_lines(state, spinner_idx);
858 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
859}
860
861fn build_live_lines(state: &LiveState, spinner_idx: usize) -> Vec<Line<'static>> {
862 let mut lines: Vec<Line<'static>> = Vec::new();
863
864 // Header line: ` <STATUS> <target> · <bundle> [· <elapsed>] `.
865 // The leading chip used to be a static ` whisker run ` brand
866 // mark; it now doubles as the phase indicator. Background color
867 // + label change with the dev loop's state, so the user reads
868 // the run's situation without parsing the trailing word.
869 let (chip_label, chip_bg, chip_fg) = status_chip(state);
870 let mut header: Vec<Span<'static>> = vec![
871 Span::styled(
872 format!(" {chip_label} "),
873 Style::default()
874 .fg(chip_fg)
875 .bg(chip_bg)
876 .add_modifier(Modifier::BOLD),
877 ),
878 Span::raw(" "),
879 Span::styled(
880 state.target.clone(),
881 Style::default().add_modifier(Modifier::BOLD),
882 ),
883 Span::styled(" · ", Style::default().fg(Color::DarkGray)),
884 Span::raw(state.bundle.clone()),
885 ];
886 if let Some(extra) = phase_elapsed(&state.phase) {
887 header.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
888 header.push(Span::styled(extra, Style::default().fg(Color::DarkGray)));
889 }
890 lines.push(Line::from(header));
891
892 // Current step line (spinner + label) OR phase-specific info.
893 match (&state.current_step, &state.phase) {
894 (Some(label), _) => {
895 let spinner = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()];
896 // Spinner colour = chip background so the eye reads the
897 // header + step row as one indicator. `chip_bg` is the
898 // RUNNING-or-BUILDING-or-PATCHING colour and is already
899 // computed for the header above; recompute here so the
900 // helper stays self-contained.
901 let (_, chip_bg, _) = status_chip(state);
902 lines.push(Line::from(vec![
903 Span::raw(" "),
904 Span::styled(spinner.to_string(), Style::default().fg(chip_bg)),
905 Span::raw(" "),
906 Span::raw(label.clone()),
907 ]));
908 }
909 (None, AppPhase::Failed { reason, .. }) => {
910 lines.push(Line::from(vec![
911 Span::raw(" "),
912 Span::styled(reason.clone(), Style::default().fg(Color::Red)),
913 ]));
914 }
915 (None, _) => {
916 lines.push(Line::from(""));
917 }
918 }
919
920 // Dev-server / clients info — shown once dev-server is bound,
921 // regardless of phase.
922 if let Some(addr) = &state.ws_addr {
923 lines.push(Line::from(vec![
924 Span::raw(" "),
925 Span::styled("dev server ", Style::default().fg(Color::DarkGray)),
926 Span::raw(format!("ws://{addr}")),
927 ]));
928 let clients = format!("{} connected", state.client_count);
929 let mut watching = vec![
930 Span::raw(" "),
931 Span::styled("clients ", Style::default().fg(Color::DarkGray)),
932 Span::raw(clients),
933 ];
934 if !state.watching.is_empty() {
935 watching.push(Span::styled(
936 " · ",
937 Style::default().fg(Color::DarkGray),
938 ));
939 watching.push(Span::styled(
940 format!("watching {} path(s)", state.watching.len()),
941 Style::default().fg(Color::DarkGray),
942 ));
943 }
944 lines.push(Line::from(watching));
945 } else {
946 // Reserve one row so the layout doesn't jiggle when the
947 // dev-server comes online mid-build.
948 lines.push(Line::from(""));
949 }
950
951 // Spacer + footer hint. The key chip uses `White` (not `Black`)
952 // on the dark-gray background so it stays legible in
953 // dark-themed terminals where ANSI color 0 (`Color::Black`)
954 // resolves to the terminal's *background* hue and visually
955 // disappears against the chip's fill.
956 lines.push(Line::from(""));
957 lines.push(Line::from(vec![
958 Span::raw(" "),
959 Span::styled(
960 " q ",
961 Style::default()
962 .fg(Color::White)
963 .bg(Color::DarkGray)
964 .add_modifier(Modifier::BOLD),
965 ),
966 Span::styled(" quit", Style::default().fg(Color::DarkGray)),
967 ]));
968
969 // Truncate / pad to LIVE_HEIGHT so the viewport renders cleanly.
970 lines.truncate(LIVE_HEIGHT as usize);
971 while lines.len() < LIVE_HEIGHT as usize {
972 lines.push(Line::from(""));
973 }
974 lines
975}
976
977fn render_history_item(item: &HistoryItem) -> Vec<Line<'static>> {
978 match item {
979 HistoryItem::PhaseEnter(label) => vec![Line::from(vec![
980 Span::styled("▶ ", Style::default().fg(Color::Cyan)),
981 Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
982 ])],
983 HistoryItem::PhaseDone {
984 label,
985 status,
986 elapsed,
987 } => {
988 let (glyph, color) = match status {
989 StepStatus::Done => ("✓ ", Color::Green),
990 StepStatus::Failed => ("✗ ", Color::Red),
991 StepStatus::Skipped => ("○ ", Color::DarkGray),
992 };
993 vec![Line::from(vec![
994 Span::styled(glyph, Style::default().fg(color)),
995 Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
996 Span::raw(" "),
997 Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
998 ])]
999 }
1000 HistoryItem::Step {
1001 label,
1002 status,
1003 elapsed,
1004 } => {
1005 let (glyph, color) = match status {
1006 StepStatus::Done => ("✓", Color::Green),
1007 StepStatus::Failed => ("✗", Color::Red),
1008 StepStatus::Skipped => ("○", Color::DarkGray),
1009 };
1010 vec![Line::from(vec![
1011 Span::raw(" "),
1012 Span::styled(glyph, Style::default().fg(color)),
1013 Span::raw(" "),
1014 Span::raw(label.clone()),
1015 Span::raw(" "),
1016 Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
1017 ])]
1018 }
1019 HistoryItem::CapturedStderr(text) => {
1020 vec![Line::from(Span::raw(text.clone()))]
1021 }
1022 HistoryItem::DeviceLog { stream, line } => {
1023 let tag = match stream.as_str() {
1024 "stderr" => "[device:err]",
1025 _ => "[device]",
1026 };
1027 vec![Line::from(vec![
1028 Span::styled(tag, Style::default().fg(Color::Magenta)),
1029 Span::raw(" "),
1030 Span::raw(line.clone()),
1031 ])]
1032 }
1033 HistoryItem::Failure(reason) => vec![Line::from(vec![
1034 Span::styled(
1035 "✗ ",
1036 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1037 ),
1038 Span::styled(reason.clone(), Style::default().fg(Color::Red)),
1039 ])],
1040 HistoryItem::SetCurrentStep(_) => {
1041 // Live-region-only; consumed by
1042 // `drain_history_into_scrollback` before reaching here.
1043 Vec::new()
1044 }
1045 }
1046}
1047
1048/// Paint `lines` into `buf` starting at the buffer's top-left.
1049/// Used by `insert_before`'s draw_fn, which gives us a buffer that
1050/// is exactly the height we asked for and the terminal's full
1051/// width.
1052fn write_lines_to_buffer(buf: &mut Buffer, lines: &[Line<'static>]) {
1053 for (i, line) in lines.iter().enumerate() {
1054 let area = Rect {
1055 x: buf.area.x,
1056 y: buf.area.y + i as u16,
1057 width: buf.area.width,
1058 height: 1,
1059 };
1060 if area.y >= buf.area.bottom() {
1061 break;
1062 }
1063 Paragraph::new(line.clone()).render(area, buf);
1064 }
1065}
1066
1067/// Picks the leading status chip's (label, background, foreground)
1068/// triple for the current live state. Combines `LiveState::phase`
1069/// with `LiveState::current_step` so a long-running step that fires
1070/// AFTER `Event::BuildSucceeded` (e.g. `xcodebuild` inside
1071/// `installer.install_and_launch` — which runs while the phase is
1072/// already `Idle`) still surfaces as `BUILDING`. Once the launch
1073/// step finishes and `current_step` clears, the chip flips to
1074/// `RUNNING`. Order of checks is significant: `Failed` outranks
1075/// everything; `Patching` outranks both Building and Idle; in-flight
1076/// step outranks bare Idle.
1077fn status_chip(state: &LiveState) -> (&'static str, Color, Color) {
1078 if matches!(state.phase, AppPhase::Failed { .. }) {
1079 return ("FAILED", Color::Red, Color::White);
1080 }
1081 if matches!(state.phase, AppPhase::Patching { .. }) {
1082 return ("PATCHING", Color::Magenta, Color::Black);
1083 }
1084 if matches!(state.phase, AppPhase::Building { .. }) {
1085 return ("BUILDING", Color::Yellow, Color::Black);
1086 }
1087 // Idle: distinguish "actively doing install / launch work" (a
1088 // step is in flight, even though the phase-machine has moved on
1089 // from Building) from "truly settled and the app is live on the
1090 // device".
1091 if matches!(state.phase, AppPhase::Idle) {
1092 if state.current_step.is_some() {
1093 return ("BUILDING", Color::Yellow, Color::Black);
1094 }
1095 return ("RUNNING", Color::Green, Color::Black);
1096 }
1097 // Setup / Initializing — the very brief pre-build phases. Render
1098 // as a neutral chip so the user knows the loop is alive but not
1099 // doing anything heavy yet.
1100 ("STARTING", Color::DarkGray, Color::White)
1101}
1102
1103fn phase_elapsed(phase: &AppPhase) -> Option<String> {
1104 match phase {
1105 AppPhase::Building { started_at, .. } | AppPhase::Patching { started_at } => {
1106 Some(fmt_elapsed(started_at.elapsed()))
1107 }
1108 _ => None,
1109 }
1110}
1111
1112fn fmt_elapsed(d: Duration) -> String {
1113 let ms = d.as_millis();
1114 if ms < 1_000 {
1115 format!("{ms}ms")
1116 } else if ms < 60_000 {
1117 format!("{:.1}s", ms as f64 / 1_000.0)
1118 } else {
1119 let secs = ms / 1_000;
1120 format!("{}m{:02}s", secs / 60, secs % 60)
1121 }
1122}
1123
1124// ============================================================================
1125// Tests
1126// ============================================================================
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131 use whisker_dev_server::Event;
1132
1133 fn s() -> LiveState {
1134 LiveState::new("iOS Simulator", "rs.whisker.podcast")
1135 }
1136
1137 fn drain(state: &mut LiveState, e: &Event) -> Vec<HistoryItem> {
1138 let mut h = Vec::new();
1139 apply_event(state, e, &mut h);
1140 h
1141 }
1142
1143 #[test]
1144 fn build_lifecycle_records_outcome() {
1145 let mut st = s();
1146 let started = drain(&mut st, &Event::BuildingFull);
1147 assert!(matches!(st.phase, AppPhase::Building { .. }));
1148 // BuildingFull no longer emits a PhaseEnter — that's
1149 // delegated to `whisker_build::ui::section` which lands in
1150 // scrollback via stderr capture.
1151 assert!(started.is_empty());
1152 let done = drain(&mut st, &Event::BuildSucceeded);
1153 assert!(matches!(st.phase, AppPhase::Idle));
1154 assert!(st.last_build.is_some());
1155 // BuildSucceeded no longer emits a `PhaseDone` summary —
1156 // the section header + per-step rows in scrollback are
1157 // enough. `last_build` is the only state mutation we care
1158 // about here, plus the phase transition above.
1159 assert!(done.is_empty());
1160 }
1161
1162 #[test]
1163 fn client_counter_saturates() {
1164 let mut st = s();
1165 drain(&mut st, &Event::ClientConnected);
1166 drain(&mut st, &Event::ClientConnected);
1167 assert_eq!(st.client_count, 2);
1168 drain(&mut st, &Event::ClientDisconnected);
1169 drain(&mut st, &Event::ClientDisconnected);
1170 drain(&mut st, &Event::ClientDisconnected);
1171 assert_eq!(st.client_count, 0);
1172 }
1173
1174 #[test]
1175 fn device_log_becomes_history_item() {
1176 let mut st = s();
1177 let h = drain(
1178 &mut st,
1179 &Event::DeviceLog {
1180 stream: "stdout".into(),
1181 line: "hello".into(),
1182 ts_micros: 0,
1183 },
1184 );
1185 assert_eq!(h.len(), 1);
1186 match &h[0] {
1187 HistoryItem::DeviceLog { stream, line } => {
1188 assert_eq!(stream, "stdout");
1189 assert_eq!(line, "hello");
1190 }
1191 other => panic!("expected DeviceLog, got {other:?}"),
1192 }
1193 }
1194
1195 #[test]
1196 fn patch_sent_records_elapsed_and_resets_phase() {
1197 let mut st = s();
1198 st.phase = AppPhase::Patching {
1199 started_at: Instant::now() - Duration::from_millis(615),
1200 };
1201 let h = drain(&mut st, &Event::PatchSent);
1202 assert!(matches!(st.phase, AppPhase::Idle));
1203 assert!(st.last_patch.is_some());
1204 // PhaseDone is no longer emitted on PatchSent — the dev-server
1205 // already emits `✓ patch tier 1 …` via `ui::step` and a
1206 // duplicate "Hot patch" summary row would just clutter
1207 // scrollback.
1208 assert!(h.is_empty());
1209 }
1210
1211 #[test]
1212 fn patch_building_transitions_phase_to_patching() {
1213 let mut st = s();
1214 st.phase = AppPhase::Idle;
1215 let h = drain(&mut st, &Event::PatchBuilding);
1216 assert!(
1217 matches!(st.phase, AppPhase::Patching { .. }),
1218 "phase should be Patching after PatchBuilding"
1219 );
1220 assert!(h.is_empty(), "PatchBuilding shouldn't emit history rows");
1221 }
1222
1223 #[test]
1224 fn build_failed_emits_failure_history() {
1225 let mut st = s();
1226 drain(&mut st, &Event::BuildingFull);
1227 let h = drain(&mut st, &Event::BuildFailed("link error".into()));
1228 assert!(matches!(st.phase, AppPhase::Failed { .. }));
1229 assert!(h.iter().any(|i| matches!(i, HistoryItem::Failure(_))));
1230 }
1231
1232 #[test]
1233 fn strip_ansi_removes_csi_sgr() {
1234 let s = "\x1b[33mwarning\x1b[0m: \x1b[1munused\x1b[0m";
1235 assert_eq!(strip_ansi(s), "warning: unused");
1236 }
1237
1238 #[test]
1239 fn strip_ansi_preserves_utf8_glyphs() {
1240 let s = "\x1b[32m✓\x1b[0m Sync gen/ios";
1241 assert_eq!(strip_ansi(s), "✓ Sync gen/ios");
1242 }
1243
1244 #[test]
1245 fn strip_ansi_drops_osc_titles() {
1246 let s = "\x1b]0;title\x07hello";
1247 assert_eq!(strip_ansi(s), "hello");
1248 }
1249
1250 #[test]
1251 fn build_live_lines_has_fixed_height() {
1252 let st = s();
1253 let lines = build_live_lines(&st, 0);
1254 assert_eq!(lines.len(), LIVE_HEIGHT as usize);
1255 }
1256
1257 #[test]
1258 fn build_live_lines_shows_current_step() {
1259 let mut st = s();
1260 st.current_step = Some("xcodebuild WhiskerDriver-Debug".into());
1261 let lines = build_live_lines(&st, 0);
1262 let rendered = lines
1263 .iter()
1264 .flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
1265 .collect::<Vec<_>>()
1266 .join("");
1267 assert!(rendered.contains("xcodebuild"));
1268 }
1269
1270 #[test]
1271 fn build_live_lines_shows_dev_server_when_set() {
1272 let mut st = s();
1273 st.ws_addr = Some("127.0.0.1:9090".into());
1274 st.client_count = 1;
1275 let lines = build_live_lines(&st, 0);
1276 let rendered = lines
1277 .iter()
1278 .flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
1279 .collect::<Vec<_>>()
1280 .join("");
1281 assert!(rendered.contains("127.0.0.1:9090"));
1282 assert!(rendered.contains("1 connected"));
1283 }
1284}