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