Skip to main content

wisp/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod cli;
4pub mod components;
5pub mod error;
6#[allow(dead_code)]
7pub mod git_diff;
8pub mod keybindings;
9pub mod runtime_state;
10mod session_loading_buffer;
11pub mod settings;
12#[cfg(test)]
13pub(crate) mod test_helpers;
14
15use acp_utils::client::AcpEvent;
16use components::app::{App, EventOutcome};
17use error::AppError;
18use runtime_state::RuntimeState;
19use std::fs::create_dir_all;
20use std::future::pending;
21use std::io;
22use std::time::Duration;
23use tokio::sync::mpsc;
24use tokio::time::interval;
25use tokio::{select, time};
26use tracing_appender::rolling::daily;
27use tracing_subscriber::EnvFilter;
28use tui::{
29    Component, CrosstermEvent, Event, MouseCapture, RendererCommand, TerminalConfig, TerminalRuntime, terminal_size,
30};
31
32/// Launch the wisp TUI with the given agent subprocess command.
33///
34/// Sets up logging, connects to the agent via ACP, and runs the interactive
35/// terminal event loop until the user exits.
36pub async fn run_tui(agent_command: &str) -> Result<(), AppError> {
37    setup_logging(None);
38    let state = RuntimeState::new(agent_command).await?;
39    run_with_state(state).await
40}
41
42/// Run the TUI from an already-initialized [`RuntimeState`].
43pub async fn run_with_state(state: RuntimeState) -> Result<(), AppError> {
44    let RuntimeState {
45        session_id,
46        agent_name,
47        prompt_capabilities,
48        config_options,
49        auth_methods,
50        theme,
51        event_rx,
52        prompt_handle,
53        working_dir,
54    } = state;
55
56    let app = App::new(
57        session_id,
58        agent_name,
59        prompt_capabilities,
60        &config_options,
61        auth_methods,
62        working_dir,
63        prompt_handle,
64    );
65
66    run_app(app, theme, event_rx).await
67}
68
69pub fn setup_logging(log_dir: Option<&str>) {
70    let dir = log_dir.unwrap_or("/tmp/wisp-logs");
71    create_dir_all(dir).ok();
72    tracing_subscriber::fmt()
73        .with_writer(daily(dir, "wisp.log"))
74        .with_ansi(false)
75        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
76        .init();
77}
78
79fn render(terminal: &mut TerminalRuntime<impl io::Write>, app: &mut App) -> Result<(), AppError> {
80    terminal.render_frame(|ctx| app.render(ctx))?;
81    Ok(())
82}
83
84const MAX_TERMINAL_EVENTS_PER_FRAME: usize = 128;
85const MAX_ACP_EVENTS_PER_FRAME: usize = 1_000;
86
87fn collect_batch<T>(first: T, max: usize, mut try_next: impl FnMut() -> Option<T>) -> Vec<T> {
88    let mut events = vec![first];
89    while events.len() < max {
90        match try_next() {
91            Some(event) => events.push(event),
92            None => break,
93        }
94    }
95    events
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99enum BatchOutcome {
100    Continue { should_render: bool },
101    Exit,
102}
103
104async fn process_terminal_event_batch(
105    terminal: &mut TerminalRuntime<impl io::Write>,
106    app: &mut App,
107    events: Vec<CrosstermEvent>,
108) -> Result<BatchOutcome, AppError> {
109    let mut should_render = false;
110
111    for event in events {
112        let tui_event = match event {
113            CrosstermEvent::Resize(cols, rows) => {
114                terminal.on_resize((cols, rows));
115                should_render = true;
116                Event::try_from(CrosstermEvent::Resize(cols, rows)).ok()
117            }
118            event => Event::try_from(event).ok(),
119        };
120
121        let Some(tui_event) = tui_event else {
122            continue;
123        };
124
125        if let Some(commands) = app.on_event(&tui_event).await {
126            terminal.apply_commands(commands)?;
127            should_render = true;
128        }
129
130        if app.exit_requested() {
131            return Ok(BatchOutcome::Exit);
132        }
133    }
134
135    Ok(BatchOutcome::Continue { should_render })
136}
137
138fn process_acp_event_batch(app: &mut App, events: Vec<AcpEvent>) -> BatchOutcome {
139    let mut should_render = false;
140    for event in events {
141        if matches!(app.on_acp_event(event), EventOutcome::Render) {
142            should_render = true;
143        }
144        if app.exit_requested() {
145            return BatchOutcome::Exit;
146        }
147    }
148    BatchOutcome::Continue { should_render }
149}
150
151async fn run_app(
152    mut app: App,
153    theme: tui::Theme,
154    mut event_rx: mpsc::UnboundedReceiver<acp_utils::client::AcpEvent>,
155) -> Result<(), AppError> {
156    let size = terminal_size().unwrap_or((80, 24));
157    let mut terminal = TerminalRuntime::new(
158        io::stdout(),
159        theme,
160        size,
161        TerminalConfig { bracketed_paste: true, mouse_capture: MouseCapture::Disabled },
162    )?;
163    let mut tick_interval = {
164        let mut tick = interval(Duration::from_millis(100));
165        tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
166        tick
167    };
168
169    let mut last_mouse_capture = false;
170    render(&mut terminal, &mut app)?;
171    loop {
172        let tick_fut = async {
173            if !app.wants_tick() {
174                pending::<()>().await;
175            }
176            tick_interval.tick().await;
177        };
178
179        select! {
180            terminal_event = terminal.next_event() => {
181                let Some(first_event) = terminal_event else {
182                    return Ok(());
183                };
184
185                let events = collect_batch(first_event, MAX_TERMINAL_EVENTS_PER_FRAME, || terminal.try_next_event());
186                if events.len() > 1 {
187                    tracing::debug!(count = events.len(), "processing terminal event batch");
188                }
189
190                match process_terminal_event_batch(&mut terminal, &mut app, events).await? {
191                    BatchOutcome::Exit => return Ok(()),
192                    BatchOutcome::Continue { should_render: true } => render(&mut terminal, &mut app)?,
193                    BatchOutcome::Continue { .. } => {}
194                }
195            }
196
197            app_event = event_rx.recv() => {
198                let Some(event) = app_event else { return Ok(()); };
199                let events = collect_batch(event, MAX_ACP_EVENTS_PER_FRAME, || event_rx.try_recv().ok());
200                if events.len() > 1 {
201                    tracing::debug!(count = events.len(), "processing ACP event batch");
202                }
203                match process_acp_event_batch(&mut app, events) {
204                    BatchOutcome::Exit => return Ok(()),
205                    BatchOutcome::Continue { should_render: true } => render(&mut terminal, &mut app)?,
206                    BatchOutcome::Continue { .. } => {}
207                }
208            }
209
210            () = tick_fut => {
211                app.on_event(&Event::Tick).await;
212                if app.exit_requested() { return Ok(()); }
213                render(&mut terminal, &mut app)?;
214            }
215        }
216
217        let capture = app.needs_mouse_capture();
218        if last_mouse_capture != capture {
219            terminal.apply_commands(vec![RendererCommand::SetMouseCapture(capture)])?;
220            last_mouse_capture = capture;
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use acp_utils::notifications::ContextClearedParams;
228
229    use crate::components::app::test_helpers::make_app;
230
231    use super::*;
232    use std::collections::VecDeque;
233
234    #[test]
235    fn collect_batch_includes_first_event() {
236        let events = collect_batch(CrosstermEvent::Resize(80, 24), 16, || None);
237
238        assert_eq!(events, vec![CrosstermEvent::Resize(80, 24)]);
239    }
240
241    #[test]
242    fn collect_batch_drains_until_empty() {
243        let mut queued = VecDeque::from([
244            CrosstermEvent::Resize(81, 24),
245            CrosstermEvent::Resize(82, 24),
246            CrosstermEvent::Resize(83, 24),
247        ]);
248
249        let events = collect_batch(CrosstermEvent::Resize(80, 24), 16, || queued.pop_front());
250
251        assert_eq!(events.len(), 4);
252        assert_eq!(events[0], CrosstermEvent::Resize(80, 24));
253        assert_eq!(events[3], CrosstermEvent::Resize(83, 24));
254    }
255
256    #[test]
257    fn collect_batch_respects_max() {
258        let mut next_width = 1;
259        let events = collect_batch(CrosstermEvent::Resize(0, 24), 4, || {
260            next_width += 1;
261            Some(CrosstermEvent::Resize(next_width, 24))
262        });
263
264        assert_eq!(events.len(), 4);
265    }
266
267    #[test]
268    fn process_acp_event_batch_exits_on_connection_closed() {
269        let mut app = make_app();
270        let outcome = process_acp_event_batch(
271            &mut app,
272            vec![AcpEvent::ContextCleared(ContextClearedParams::default()), AcpEvent::ConnectionClosed],
273        );
274
275        assert_eq!(outcome, BatchOutcome::Exit);
276    }
277
278    #[test]
279    fn process_acp_event_batch_continue_renders_when_any_event_dirties() {
280        let mut app = make_app();
281        let outcome =
282            process_acp_event_batch(&mut app, vec![AcpEvent::ContextCleared(ContextClearedParams::default())]);
283        assert_eq!(outcome, BatchOutcome::Continue { should_render: true });
284    }
285
286    #[test]
287    fn process_acp_event_batch_empty_input_does_not_render() {
288        let mut app = make_app();
289        let outcome = process_acp_event_batch(&mut app, vec![]);
290        assert_eq!(outcome, BatchOutcome::Continue { should_render: false });
291    }
292}