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
34pub 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
44pub 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}