synaps-tui 0.3.7

Terminal UI layer — ratatui, crossterm, syntect, tachyonfx
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! Dedicated render `std::thread` for the TUI.
//!
//! ## Design (spec §5)
//!
//! The render thread owns the `Terminal<CrosstermBackend<Stdout>>` end-to-end,
//! including final teardown.  The main tokio task never touches the terminal
//! after handing it off at startup.
//!
//! ### Publish protocol (latest-wins mailbox)
//!
//! A `FrameSlot` wraps `Arc<parking_lot::Mutex<Option<Arc<RenderModel>>>>`.
//! The main task calls `FrameSlot::publish(model)` which:
//!
//! 1. Locks the inner mutex and stores the new model (replacing any unread
//!    frame — latest-wins).
//! 2. Calls `Thread::unpark()` on the render thread to wake it.
//!
//! The render thread loops:
//!
//! ```text
//! loop {
//!     thread::park();                       // sleep until a frame arrives
//!     // drain commands …
//!     while let Some(model) = inner.lock().take() { render_frame(…); }
//!     if disconnected { break; }
//! }
//! ```
//!
//! Spurious wakeups from `park()` are safe — the inner `while let` re-checks
//! the slot; if it's empty the inner loop exits and we re-park.
//!
//! ### Sideband command channel
//!
//! A `std::sync::mpsc::Sender<RenderCmd>` carries out-of-band commands.
//! Currently `Teardown { ack }`, `SpawnBootFx`, and `SpawnExitFx` are
//! defined.  Main sends `Teardown`, then waits on the ack `Receiver<()>`
//! inside the existing bounded-teardown budget.  The render thread performs
//! the full terminal cleanup sequence, then sends `()` on the ack channel.
//!
//! ### Terminal size on the main side (spec §5.4, choice documented here)
//!
//! **We call `crossterm::terminal::size()` directly on the main side.**
//! The spec lists this as the simplest option — it reads the TTY fd directly
//! and does not need the `Terminal` object.  No shared `AtomicU16` needed.
//! Worst-case: one stale frame on resize (ratatui re-layouts on the next
//! frame using the terminal's actual size anyway).

use std::io;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Once;
use std::time::Instant;

use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tachyonfx::{Effect, Shader};

use super::draw::render_frame;
use super::lifecycle;
use super::render_model::RenderModel;

// ── Public command enum ───────────────────────────────────────────────────────

/// Out-of-band commands sent from the main task to the render thread via the
/// sideband `mpsc` channel.
pub(crate) enum RenderCmd {
    /// Request clean teardown.  The render thread will restore the terminal,
    /// send `()` on `ack`, then exit its loop.  Main waits on `ack` inside
    /// the existing bounded-teardown budget.
    Teardown { ack: mpsc::SyncSender<()> },

    /// Spawn the boot (entry) tachyonfx effect.  Sent once at startup.
    SpawnBootFx { fx: Effect },

    /// Spawn the exit tachyonfx effect.  Sent when the user invokes `/quit`.
    SpawnExitFx { fx: Effect },

    /// Force a full terminal clear on the next render pass.  Sent after gamba
    /// exits and similar full-screen takeover events where ratatui's diff
    /// cannot know the screen is dirty.
    Clear,

    /// Pause rendering so the main thread can safely change terminal modes
    /// (e.g. for casino / gamba takeover).  The render thread finishes any
    /// in-flight draw, sends `()` on `ack` to prove it is idle, then parks
    /// ignoring further publishes until it receives `Resume`.
    ///
    /// Main MUST wait on `ack` before touching the terminal.  A bounded
    /// timeout (~2 s) applies — if the ack doesn't arrive the render thread
    /// is wedged and the casino will reset the terminal anyway, so we
    /// proceed regardless.
    Pause { ack: mpsc::SyncSender<()> },

    /// Resume rendering after a `Pause`.  The render thread exits the paused
    /// state and forces a full repaint (equivalent to `Clear`) so ratatui
    /// repaints from scratch after the casino scribbled on the screen.
    Resume,
}

// ── Latest-wins frame slot ────────────────────────────────────────────────────

/// Single-slot latest-wins mailbox.  Shared between the main task (publisher)
/// and the render thread (consumer).
#[derive(Clone)]
pub(crate) struct FrameSlot {
    inner:         Arc<parking_lot::Mutex<Option<Arc<RenderModel>>>>,
    render_thread: std::thread::Thread,
}

impl FrameSlot {
    fn new(
        inner:         Arc<parking_lot::Mutex<Option<Arc<RenderModel>>>>,
        render_thread: std::thread::Thread,
    ) -> Self {
        FrameSlot { inner, render_thread }
    }

    /// Publish a new frame snapshot.  Replaces any unread frame (latest-wins)
    /// and wakes the render thread via `unpark()`.
    pub(crate) fn publish(&self, model: Arc<RenderModel>) {
        *self.inner.lock() = Some(model);
        self.render_thread.unpark();
    }
}

// ── Public handle returned to the caller ─────────────────────────────────────

/// Handle to the render thread, held by the main task.
pub(crate) struct RenderHandle {
    slot:              FrameSlot,
    pub(crate) cmd_tx: mpsc::Sender<RenderCmd>,
    join_handle:       Option<std::thread::JoinHandle<()>>,
}

impl RenderHandle {
    /// Publish a new frame snapshot to the render thread (latest-wins).
    /// Replaces any unread frame and wakes the render thread via `unpark()`.
    pub(crate) fn publish(&self, model: std::sync::Arc<super::render_model::RenderModel>) {
        self.slot.publish(model);
    }

    /// Wake the render thread (in addition to an unpark from publish).
    /// Used after sending a command so the thread wakes and processes it.
    fn wake(&self) {
        self.slot.render_thread.unpark();
    }

    /// Send the `SpawnBootFx` command (best-effort; ignore if thread is gone).
    pub(crate) fn send_boot_fx(&self, fx: Effect) {
        let _ = self.cmd_tx.send(RenderCmd::SpawnBootFx { fx });
        self.wake();
    }

    /// Send the `SpawnExitFx` command (best-effort).
    pub(crate) fn send_exit_fx(&self, fx: Effect) {
        let _ = self.cmd_tx.send(RenderCmd::SpawnExitFx { fx });
        self.wake();
    }

    /// Send a `Clear` command so the render thread calls `terminal.clear()`
    /// before its next render pass.  Used after full-screen takeover events
    /// (gamba exit, etc.) where ratatui's diff does not know the screen is
    /// dirty.
    pub(crate) fn send_clear(&self) {
        let _ = self.cmd_tx.send(RenderCmd::Clear);
        self.wake();
    }

    /// Pause the render thread before a terminal-mode handoff (e.g. casino).
    ///
    /// Sends `Pause`, then blocks (up to ~2 s) for the ack that proves the
    /// render thread is idle and out of `terminal.draw()`.  Returns `true` if
    /// the ack arrived; `false` if the render thread is wedged (caller should
    /// proceed anyway — the casino will reset the terminal).
    ///
    /// After this returns `true` it is safe for the main thread to call
    /// `disable_raw_mode()`, `LeaveAlternateScreen`, etc.
    pub(crate) fn pause(&self) -> bool {
        let (ack_tx, ack_rx) = mpsc::sync_channel::<()>(1);
        let _ = self.cmd_tx.send(RenderCmd::Pause { ack: ack_tx });
        self.wake();
        ack_rx.recv_timeout(std::time::Duration::from_secs(2)).is_ok()
    }

    /// Resume the render thread after the terminal has been restored by the
    /// main thread.  Triggers a forced full repaint so ratatui redraws from
    /// scratch (the casino may have left the screen in any state).
    pub(crate) fn resume(&self) {
        let _ = self.cmd_tx.send(RenderCmd::Resume);
        self.wake();
    }

    /// Perform a clean, bounded teardown of the render thread.
    ///
    /// Sends `Teardown`, waits for the ack (up to `timeout`).  If the ack
    /// arrives the thread is exiting cleanly, so we join it (quick).  If it
    /// does NOT arrive within the budget the thread is wedged — almost always
    /// because its own teardown `write()` is blocked on a dead PTY consumer —
    /// so we deliberately do **not** join (that would hang the process on
    /// exit).  The wedged thread is reaped when the process exits.  This
    /// self-bounding behaviour is what replaced the old signal watchdog (#116).
    ///
    /// Returns `true` if the ack arrived within the timeout.
    pub(crate) fn teardown(mut self, timeout: std::time::Duration) -> bool {
        let (ack_tx, ack_rx) = mpsc::sync_channel::<()>(1);
        let _ = self.cmd_tx.send(RenderCmd::Teardown { ack: ack_tx });
        self.wake();
        let acked = ack_rx.recv_timeout(timeout).is_ok();
        if acked {
            if let Some(handle) = self.join_handle.take() {
                // Acked → the thread restored the terminal and is returning;
                // join is quick.
                let _ = handle.join();
            }
        }
        // If NOT acked, the render thread is wedged (e.g. blocked on a dead
        // PTY). Skip the join — blocking here would hang shutdown forever.
        // Dropping the handle detaches the thread; it dies on process exit.
        acked
    }
}

impl Drop for RenderHandle {
    fn drop(&mut self) {
        // If teardown() was not called (e.g. panic path on the main task),
        // we need to wake the render thread so it can observe the disconnect
        // and run do_teardown() itself.
        //
        // Order matters:
        //  1. Disconnect cmd_rx by replacing our sender with one whose receiver
        //     is immediately dropped.  The render thread's cmd_rx will then
        //     return Disconnected on the next try_recv, triggering do_teardown().
        //  2. Unpark the render thread so it wakes from park() immediately and
        //     processes the disconnect — instead of sleeping forever in raw mode.
        //  3. Drop the JoinHandle last (detaches the thread; OS reaps on exit).
        //
        // If teardown() already ran it consumed join_handle via .take(), so
        // the drop below is a no-op and the thread has already exited cleanly.
        let (dead_tx, _dead_rx) = mpsc::channel::<RenderCmd>();
        // _dead_rx is dropped at end of block, so dead_tx is already the sole
        // sender of a disconnected channel.  Swap it in and drop the real tx.
        let _old_tx = std::mem::replace(&mut self.cmd_tx, dead_tx);
        drop(_old_tx);  // now render thread's cmd_rx sees Disconnected
        self.slot.render_thread.unpark();
        drop(self.join_handle.take());
    }
}

// ── Panic hook (installed once per process) ───────────────────────────────

static PANIC_HOOK_INSTALLED: Once = Once::new();

/// Install the process-wide panic hook that restores the terminal before the
/// default hook prints the panic message.  Safe to call multiple times —
/// internally guarded by a [`Once`].
///
/// The hook chains to whatever hook was previously installed (usually the
/// default Rust hook that prints the backtrace), so existing behaviour is
/// preserved.
fn install_panic_hook_once() {
    PANIC_HOOK_INSTALLED.call_once(|| {
        let prev = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |info| {
            // Restore the terminal first so the panic message is readable.
            super::lifecycle::emergency_teardown_terminal();
            prev(info);
        }));
    });
}

// ── Thread spawn ─────────────────────────────────────────────────────────────

/// Spawn the dedicated render `std::thread`.
///
/// Moves `terminal` into the thread — the main task must NOT use `terminal`
/// after this call.
///
/// Returns:
/// - A [`RenderHandle`] for publishing frames and sending teardown.
/// - An `Arc<AtomicBool>` (`boot_done`) that becomes `true` when the boot
///   effect finishes — the main task clears its `boot_fx_sent` guard on this.
/// - An `Arc<AtomicBool>` (`exit_done`) that becomes `true` when the exit
///   effect finishes — the main task breaks the event loop on this.
pub(crate) fn spawn_render_thread(
    terminal: Terminal<CrosstermBackend<io::Stdout>>,
) -> (RenderHandle, Arc<AtomicBool>, Arc<AtomicBool>) {
    install_panic_hook_once();
    // Shared inner slot — same Arc goes into the FrameSlot (main) and the
    // thread closure (render side).  No circular dependency.
    let inner: Arc<parking_lot::Mutex<Option<Arc<RenderModel>>>> =
        Arc::new(parking_lot::Mutex::new(None));
    let inner_thread = Arc::clone(&inner);

    let (cmd_tx, cmd_rx) = mpsc::channel::<RenderCmd>();
    let boot_done = Arc::new(AtomicBool::new(false));
    let boot_done_thread = Arc::clone(&boot_done);
    let exit_done = Arc::new(AtomicBool::new(false));
    let exit_done_thread = Arc::clone(&exit_done);

    // Oneshot to receive the thread's own `Thread` handle so we can build
    // the FrameSlot and call `unpark()` later.
    let (thread_tx, thread_rx) = mpsc::sync_channel::<std::thread::Thread>(1);

    let join_handle = std::thread::Builder::new()
        .name("agent-tui-render".to_string())
        .spawn(move || {
            // Send our Thread handle to the main side immediately — this is
            // the bootstrap for the unpark/park synchronisation.
            let _ = thread_tx.send(std::thread::current());

            // Wrap the entire body in catch_unwind so a panic in render_frame
            // (or anywhere in the render loop) does NOT leave the terminal in
            // raw mode.  Whether the body returns normally OR unwinds, we ALWAYS
            // ensure terminal cleanup and signal exit_done so the main loop wakes.
            //
            // We use a Cell/Option to regain the terminal after catch_unwind so
            // we can call do_teardown() ourselves even on the panic path.
            let mut terminal_opt = Some(terminal);
            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                // Safety: we're the only thread touching terminal_opt here.
                let term = terminal_opt.take().expect("terminal already taken");
                render_thread_body(term, inner_thread, cmd_rx, boot_done_thread, Arc::clone(&exit_done_thread));
                // render_thread_body returned normally — it already called
                // do_teardown() before returning.  terminal was consumed.
            }));

            if let Err(payload) = result {
                // Log the panic payload.
                let msg = payload
                    .downcast_ref::<&str>()
                    .copied()
                    .or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
                    .unwrap_or("<non-string panic payload>");
                tracing::error!(panic = msg, "render thread panicked — restoring terminal");
                // The panic hook already called emergency_teardown_terminal().
                // If terminal_opt still has a value (panic before take()), call
                // do_teardown() to also run show_cursor() and the full sequence.
                if let Some(mut term) = terminal_opt {
                    do_teardown(&mut term);
                }
                // Signal the main loop that the render thread is done so it
                // doesn't block forever waiting for the exit effect.
                exit_done_thread.store(true, Ordering::Release);
            }
        })
        .expect("failed to spawn render thread");

    // Block (briefly) until the thread has sent its handle.  This completes
    // before any event-loop iteration on the main side.
    let render_thread = thread_rx.recv().expect("render thread failed to send its Thread handle");

    let slot = FrameSlot::new(inner, render_thread);
    let handle = RenderHandle {
        slot,
        cmd_tx,
        join_handle: Some(join_handle),
    };

    (handle, boot_done, exit_done)
}

// ── Thread body ───────────────────────────────────────────────────────────────

fn render_thread_body(
    mut terminal: Terminal<CrosstermBackend<io::Stdout>>,
    inner:        Arc<parking_lot::Mutex<Option<Arc<RenderModel>>>>,
    cmd_rx:       mpsc::Receiver<RenderCmd>,
    boot_done:    Arc<AtomicBool>,
    exit_done:    Arc<AtomicBool>,
) {
    // The render thread's own monotonic clock for tachyonfx effect timing.
    // Independent of main-loop pressure: if the main task is busy, animations
    // still advance at the render thread's cadence.
    let mut last_frame = Instant::now();

    let mut boot_fx:      Option<Effect> = None;
    let mut exit_fx:      Option<Effect> = None;
    let mut pending_clear = false;

    // When the main thread sends Pause, we enter a "paused" state: we ack
    // immediately (proving we're idle), then park and drop incoming frames
    // until we receive Resume.  While paused, the main thread owns the
    // terminal exclusively.
    let mut paused = false;

    loop {
        // Park until the main task publishes a frame or sends a command.
        // Spurious wakeups are safe: the inner loops below re-check state.
        std::thread::park();

        // ── 1. Drain the command channel (higher priority than frame render) ──
        loop {
            match cmd_rx.try_recv() {
                Ok(RenderCmd::SpawnBootFx { fx }) => {
                    boot_fx = Some(fx);
                }
                Ok(RenderCmd::SpawnExitFx { fx }) => {
                    exit_fx = Some(fx);
                    exit_done.store(false, Ordering::Release);
                }
                Ok(RenderCmd::Clear) => {
                    pending_clear = true;
                }
                Ok(RenderCmd::Pause { ack }) => {
                    // We are between draws here (command drain happens before
                    // the render step).  Drop any pending frame from the slot
                    // so nothing is rendered while paused, then ack — this
                    // proves to the main thread that we are idle.
                    inner.lock().take();
                    let _ = ack.send(());
                    paused = true;
                }
                Ok(RenderCmd::Resume) => {
                    paused = false;
                    // Force a full repaint: the casino may have left the
                    // screen in any state; ratatui must redraw from scratch.
                    pending_clear = true;
                }
                Ok(RenderCmd::Teardown { ack }) => {
                    // Full terminal restoration — must happen before ack.
                    do_teardown(&mut terminal);
                    let _ = ack.send(());
                    return;   // exit the thread
                }
                Err(mpsc::TryRecvError::Empty) => break,
                Err(mpsc::TryRecvError::Disconnected) => {
                    // Main dropped its sender without sending Teardown —
                    // emergency cleanup and exit.
                    do_teardown(&mut terminal);
                    return;
                }
            }
        }

        // While paused, drain and discard any published frames, then re-park.
        // We must NOT call terminal.draw() while the main thread owns stdout.
        if paused {
            inner.lock().take();
            continue;
        }

        // ── 2. Apply pending clear before rendering ───────────────────────────
        if pending_clear {
            terminal.clear().ok();
            pending_clear = false;
        }

        // ── 3. Render the latest pending frame (latest-wins: drain = take once)
        // We take under the lock, then render outside it so the main task can
        // publish the next frame concurrently while we're writing.
        while let Some(model) = inner.lock().take() {
            if let Err(e) = render_frame(
                &mut terminal,
                &model,
                &mut boot_fx,
                &mut exit_fx,
                &mut last_frame,
            ) {
                tracing::warn!(err = %e, "render thread: terminal write failed — PTY likely closed");
                // Do NOT teardown here — stay alive so main's bounded-teardown
                // can send the Teardown command and get a clean exit sequence.
            }
        }

        // ── 4. Effect done checks ────────────────────────────────────────────
        // After every render pass, check if effects have completed.
        // `.done()` is only meaningful once `fx.process()` has been called
        // at least once (i.e. the effect has ticked).
        if boot_fx.as_ref().is_some_and(|fx| fx.done()) {
            boot_done.store(true, Ordering::Release);
            boot_fx = None;  // boot effect is done; release resources
        }
        if exit_fx.as_ref().is_some_and(|fx| fx.done()) {
            exit_done.store(true, Ordering::Release);
            // Don't clear exit_fx — keep it alive so it continues to render
            // on the final frame(s) before teardown.
        }
    }
}

/// Restore the terminal to a sane state.  Called on teardown and on
/// unexpected disconnection of the command channel.
fn do_teardown(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) {
    lifecycle::emergency_teardown_terminal();
    terminal.show_cursor().ok();
    // The Terminal is dropped when the thread exits — that's fine; the
    // crossterm cleanup has already happened above.
}