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