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