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