Skip to main content

muster/adapter/tui/
runtime.rs

1use std::{
2    ops::ControlFlow,
3    path::PathBuf,
4    thread,
5    time::{Duration, Instant},
6};
7
8use crossbeam_channel::{Receiver, Sender, after, bounded, never, select, unbounded};
9use crossterm::event;
10use ratatui::{
11    layout::{Rect, Size},
12    style::Style,
13};
14use typed_builder::TypedBuilder;
15
16use super::{TerminalGuard, app::App, event::RuntimeEvent, watch::NotifyConfigWatcher};
17use crate::{
18    application::Workspace,
19    domain::port::{
20        AgentSessionStore, Notifier, PathCompleter, ProcessRunner, ProjectRegistry, SettingsStore,
21    },
22    error::Result,
23};
24
25/// The driven adapters the TUI runs on, bundled so [`run`] takes one wiring
26/// object instead of a long argument list. Built at the composition root and
27/// consumed (moved) into the app. No `Getters`: the fields are `Box<dyn _>` and
28/// are moved out once, not borrowed.
29#[derive(TypedBuilder)]
30pub struct Adapters {
31    runner: Box<dyn ProcessRunner>,
32    registry: Box<dyn ProjectRegistry>,
33    completer: Box<dyn PathCompleter + Send>,
34    notifier: Box<dyn Notifier>,
35    settings_store: Box<dyn SettingsStore>,
36    agent_session_store: Box<dyn AgentSessionStore>,
37}
38
39/// Poll timeout for the input reader thread.
40const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
41/// Bounded output-channel capacity; back-pressures noisy PTYs so memory stays
42/// bounded instead of growing with an ever-larger backlog.
43const OUTPUT_CAPACITY: usize = 1024;
44/// Maximum output events drained per iteration before a single redraw.
45const MAX_BATCH: usize = 512;
46
47/// Runs the TUI. Terminal input flows on its own unbounded channel drained with
48/// priority, so keystrokes are never blocked behind a flood of process output on
49/// the bounded output channel.
50///
51/// # Errors
52/// Returns an error if querying the terminal size or drawing a frame fails.
53pub fn run(
54    guard: &mut TerminalGuard,
55    workspace: Workspace,
56    adapters: Adapters,
57    current_config: PathBuf,
58    selection_style: Style,
59) -> Result<()> {
60    let Adapters {
61        runner,
62        registry,
63        completer,
64        notifier,
65        settings_store,
66        agent_session_store,
67    } = adapters;
68    let (control_tx, control_rx) = unbounded();
69    let (output_tx, output_rx) = bounded(OUTPUT_CAPACITY);
70    let watch_tx = output_tx.clone();
71    spawn_input_thread(control_tx);
72
73    let area = size_to_rect(guard.terminal_mut().size()?);
74    let mut app = App::new(
75        workspace,
76        runner,
77        output_tx,
78        area,
79        completer,
80        registry,
81        current_config,
82    );
83    app.spawn_completion_worker();
84    app.set_config_watcher(Box::new(NotifyConfigWatcher::new(watch_tx)));
85    app.set_notifier(notifier);
86    app.set_settings_store(settings_store);
87    app.set_agent_session_store(agent_session_store);
88    app.set_selection_style(selection_style);
89    app.start();
90
91    // Children are running now, so shut them down on every return path,
92    // including a draw error, rather than leaking them.
93    let result = run_loop(guard, &mut app, &control_rx, &output_rx);
94    app.shutdown();
95    result
96}
97
98/// Drives the draw/update loop until the user quits or terminal input closes.
99///
100/// # Errors
101/// Returns an error if drawing a frame fails.
102fn run_loop(
103    guard: &mut TerminalGuard,
104    app: &mut App,
105    control_rx: &Receiver<RuntimeEvent>,
106    output_rx: &Receiver<RuntimeEvent>,
107) -> Result<()> {
108    guard.terminal_mut().draw(|frame| app.render(frame))?;
109    while app.is_running() {
110        let outcome = drain(app, control_rx, output_rx);
111        if let Some(text) = app.take_pending_clipboard() {
112            guard.copy_to_clipboard(&text)?;
113        }
114        if let Some(shape) = app.take_pending_pointer_shape() {
115            guard.set_pointer_shape(shape)?;
116        }
117        match outcome {
118            ControlFlow::Break(()) => break,
119            ControlFlow::Continue(redraw) if redraw && app.is_running() => {
120                app.refresh_selection_view();
121                guard.terminal_mut().draw(|frame| app.render(frame))?;
122            },
123            ControlFlow::Continue(_) => {},
124        }
125    }
126    Ok(())
127}
128
129/// Blocks for the next event or activity deadline, then drains all pending input
130/// (priority) followed by a bounded batch of output. Returns whether to redraw,
131/// or `Break` when the loop should stop.
132fn drain(
133    app: &mut App,
134    control_rx: &Receiver<RuntimeEvent>,
135    output_rx: &Receiver<RuntimeEvent>,
136) -> ControlFlow<(), bool> {
137    let activity_timeout = app
138        .next_activity_deadline()
139        .map(|deadline| after(deadline.saturating_duration_since(Instant::now())))
140        .unwrap_or_else(never);
141    let activity_frame_timeout = app
142        .next_activity_frame_deadline()
143        .map(|deadline| after(deadline.saturating_duration_since(Instant::now())))
144        .unwrap_or_else(never);
145    let selection_timeout = app
146        .next_selection_deadline()
147        .map(|deadline| after(deadline.saturating_duration_since(Instant::now())))
148        .unwrap_or_else(never);
149    let mut redraw = false;
150    select! {
151        recv(control_rx) -> msg => match msg {
152            Ok(event) => if !apply(app, event) {
153                return ControlFlow::Break(());
154            } else {
155                redraw = true;
156            },
157            Err(_) => return ControlFlow::Break(()),
158        },
159        recv(output_rx) -> msg => if let Ok(event) = msg {
160            if !apply(app, event) {
161                return ControlFlow::Break(());
162            }
163            redraw = true;
164        },
165        recv(activity_timeout) -> now => if let Ok(now) = now {
166            redraw = app.expire_quiet_activity(now);
167        },
168        recv(activity_frame_timeout) -> now => if let Ok(now) = now {
169            redraw = app.advance_activity_frame(now);
170        },
171        recv(selection_timeout) -> now => if let Ok(now) = now {
172            redraw = app.advance_selection(now);
173        },
174    }
175    while let Ok(event) = control_rx.try_recv() {
176        if !apply(app, event) {
177            return ControlFlow::Break(());
178        }
179        redraw = true;
180    }
181    for _ in 0..MAX_BATCH {
182        match output_rx.try_recv() {
183            Ok(event) => {
184                if !apply(app, event) {
185                    return ControlFlow::Break(());
186                }
187                redraw = true;
188            },
189            Err(_) => break,
190        }
191    }
192    let now = Instant::now();
193    redraw |= app.advance_activity_frame(now);
194    redraw |= app.advance_selection(now);
195    ControlFlow::Continue(redraw)
196}
197
198/// Applies one event to the app; returns `false` when the loop should stop.
199fn apply(app: &mut App, event: RuntimeEvent) -> bool {
200    match event {
201        RuntimeEvent::Input(event) => app.handle_input(event),
202        RuntimeEvent::Output {
203            pane,
204            generation,
205            output,
206        } => app.handle_output(pane, generation, output),
207        RuntimeEvent::Respawn { pane, generation } => app.handle_respawn(pane, generation),
208        RuntimeEvent::ForceStop {
209            pane,
210            spawn_generation,
211            shutdown_generation,
212        } => app.handle_force_stop(pane, spawn_generation, shutdown_generation),
213        RuntimeEvent::Completions {
214            generation,
215            candidates,
216        } => app.handle_completions(generation, candidates),
217        RuntimeEvent::ConfigChanged { path } => app.handle_config_changed(path),
218        RuntimeEvent::InputClosed => return false,
219    }
220    true
221}
222
223/// Spawns a thread forwarding crossterm input onto the control channel, sending
224/// `InputClosed` if the input source errors so the loop never blocks forever.
225fn spawn_input_thread(sender: Sender<RuntimeEvent>) {
226    thread::spawn(move || {
227        loop {
228            match event::poll(INPUT_POLL_INTERVAL) {
229                Ok(true) => match event::read() {
230                    Ok(event) => {
231                        if sender.send(RuntimeEvent::Input(event)).is_err() {
232                            break;
233                        }
234                    },
235                    Err(_) => {
236                        let _ = sender.send(RuntimeEvent::InputClosed);
237                        break;
238                    },
239                },
240                Ok(false) => {},
241                Err(_) => {
242                    let _ = sender.send(RuntimeEvent::InputClosed);
243                    break;
244                },
245            }
246        }
247    });
248}
249
250/// Converts a terminal size into a full-screen rectangle.
251fn size_to_rect(size: Size) -> Rect {
252    Rect::new(0, 0, size.width, size.height)
253}