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
33pub 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
43pub 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
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum BatchOutcome {
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<BatchOutcome, 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(BatchOutcome::Exit);
135 }
136 }
137
138 Ok(BatchOutcome::Continue { should_render })
139}
140
141fn process_acp_event_batch(app: &mut App, events: Vec<AcpEvent>) -> BatchOutcome {
142 let mut should_render = false;
143 for event in events {
144 if matches!(app.on_acp_event(event), EventOutcome::Render) {
145 should_render = true;
146 }
147 if app.exit_requested() {
148 return BatchOutcome::Exit;
149 }
150 }
151 BatchOutcome::Continue { should_render }
152}
153
154async fn run_app(
155 mut app: App,
156 theme: tui::Theme,
157 mut event_rx: mpsc::UnboundedReceiver<acp_utils::client::AcpEvent>,
158) -> Result<(), AppError> {
159 let size = terminal_size().unwrap_or((80, 24));
160 let mut terminal = TerminalRuntime::new(
161 io::stdout(),
162 theme,
163 size,
164 TerminalConfig { bracketed_paste: true, mouse_capture: MouseCapture::Disabled },
165 )?;
166 let mut tick_interval = {
167 let mut tick = interval(Duration::from_millis(100));
168 tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
169 tick
170 };
171
172 let mut last_mouse_capture = false;
173 render(&mut terminal, &mut app)?;
174 loop {
175 let tick_fut = async {
176 if !app.wants_tick() {
177 pending::<()>().await;
178 }
179 tick_interval.tick().await;
180 };
181
182 select! {
183 terminal_event = terminal.next_event() => {
184 let Some(first_event) = terminal_event else {
185 return Ok(());
186 };
187
188 let events = collect_batch(first_event, MAX_TERMINAL_EVENTS_PER_FRAME, || terminal.try_next_event());
189 if events.len() > 1 {
190 tracing::debug!(count = events.len(), "processing terminal event batch");
191 }
192
193 match process_terminal_event_batch(&mut terminal, &mut app, events).await? {
194 BatchOutcome::Exit => return Ok(()),
195 BatchOutcome::Continue { should_render: true } => render(&mut terminal, &mut app)?,
196 BatchOutcome::Continue { .. } => {}
197 }
198 }
199
200 app_event = event_rx.recv() => {
201 let Some(event) = app_event else { return Ok(()); };
202 let events = collect_batch(event, MAX_ACP_EVENTS_PER_FRAME, || event_rx.try_recv().ok());
203 if events.len() > 1 {
204 tracing::debug!(count = events.len(), "processing ACP event batch");
205 }
206 match process_acp_event_batch(&mut app, events) {
207 BatchOutcome::Exit => return Ok(()),
208 BatchOutcome::Continue { should_render: true } => render(&mut terminal, &mut app)?,
209 BatchOutcome::Continue { .. } => {}
210 }
211 }
212
213 () = tick_fut => {
214 app.on_event(&Event::Tick).await;
215 if app.exit_requested() { return Ok(()); }
216 render(&mut terminal, &mut app)?;
217 }
218 }
219
220 let capture = app.needs_mouse_capture();
221 if last_mouse_capture != capture {
222 terminal.apply_commands(vec![RendererCommand::SetMouseCapture(capture)])?;
223 last_mouse_capture = capture;
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use acp_utils::notifications::ContextClearedParams;
231
232 use crate::components::app::test_helpers::make_app;
233
234 use super::*;
235 use std::collections::VecDeque;
236
237 #[test]
238 fn collect_batch_includes_first_event() {
239 let events = collect_batch(CrosstermEvent::Resize(80, 24), 16, || None);
240
241 assert_eq!(events, vec![CrosstermEvent::Resize(80, 24)]);
242 }
243
244 #[test]
245 fn collect_batch_drains_until_empty() {
246 let mut queued = VecDeque::from([
247 CrosstermEvent::Resize(81, 24),
248 CrosstermEvent::Resize(82, 24),
249 CrosstermEvent::Resize(83, 24),
250 ]);
251
252 let events = collect_batch(CrosstermEvent::Resize(80, 24), 16, || queued.pop_front());
253
254 assert_eq!(events.len(), 4);
255 assert_eq!(events[0], CrosstermEvent::Resize(80, 24));
256 assert_eq!(events[3], CrosstermEvent::Resize(83, 24));
257 }
258
259 #[test]
260 fn collect_batch_respects_max() {
261 let mut next_width = 1;
262 let events = collect_batch(CrosstermEvent::Resize(0, 24), 4, || {
263 next_width += 1;
264 Some(CrosstermEvent::Resize(next_width, 24))
265 });
266
267 assert_eq!(events.len(), 4);
268 }
269
270 #[test]
271 fn process_acp_event_batch_exits_on_connection_closed() {
272 let mut app = make_app();
273 let outcome = process_acp_event_batch(
274 &mut app,
275 vec![AcpEvent::ContextCleared(ContextClearedParams::default()), AcpEvent::ConnectionClosed],
276 );
277
278 assert_eq!(outcome, BatchOutcome::Exit);
279 }
280
281 #[test]
282 fn process_acp_event_batch_continue_renders_when_any_event_dirties() {
283 let mut app = make_app();
284 let outcome =
285 process_acp_event_batch(&mut app, vec![AcpEvent::ContextCleared(ContextClearedParams::default())]);
286 assert_eq!(outcome, BatchOutcome::Continue { should_render: true });
287 }
288
289 #[test]
290 fn process_acp_event_batch_empty_input_does_not_render() {
291 let mut app = make_app();
292 let outcome = process_acp_event_batch(&mut app, vec![]);
293 assert_eq!(outcome, BatchOutcome::Continue { should_render: false });
294 }
295}