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
30pub 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
40pub 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
82async fn run_app(
83 mut app: App,
84 theme: tui::Theme,
85 mut event_rx: mpsc::UnboundedReceiver<acp_utils::client::AcpEvent>,
86) -> Result<(), AppError> {
87 let size = terminal_size().unwrap_or((80, 24));
88 let mut terminal = TerminalRuntime::new(
89 io::stdout(),
90 theme,
91 size,
92 TerminalConfig { bracketed_paste: true, mouse_capture: MouseCapture::Disabled },
93 )?;
94 let mut tick_interval = {
95 let mut tick = interval(Duration::from_millis(100));
96 tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
97 tick
98 };
99
100 let mut last_mouse_capture = false;
101 render(&mut terminal, &mut app)?;
102 loop {
103 let tick_fut = async {
104 if !app.wants_tick() {
105 pending::<()>().await;
106 }
107 tick_interval.tick().await;
108 };
109
110 select! {
111 terminal_event = terminal.next_event() => {
112 let Some(event) = terminal_event else {
113 return Ok(());
114 };
115 if let CrosstermEvent::Resize(cols, rows) = &event {
116 terminal.on_resize((*cols, *rows));
117 }
118 if let Ok(tui_event) = Event::try_from(event) {
119 let commands = app.on_event(&tui_event).await.unwrap_or_default();
120 terminal.apply_commands(commands)?;
121 if app.exit_requested() { return Ok(()); }
122 render(&mut terminal, &mut app)?;
123 }
124 }
125
126 app_event = event_rx.recv() => {
127 match app_event {
128 Some(event) => {
129 app.on_acp_event(event);
130 if app.exit_requested() { return Ok(()); }
131 render(&mut terminal, &mut app)?;
132 }
133 None => return Ok(()),
134 }
135 }
136
137 () = tick_fut => {
138 app.on_event(&Event::Tick).await;
139 if app.exit_requested() { return Ok(()); }
140 render(&mut terminal, &mut app)?;
141 }
142 }
143
144 let capture = app.needs_mouse_capture();
145 if last_mouse_capture != capture {
146 terminal.apply_commands(vec![RendererCommand::SetMouseCapture(capture)])?;
147 last_mouse_capture = capture;
148 }
149 }
150}