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