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, Renderer, RendererCommand, TerminalSession,
28 spawn_terminal_event_task, terminal_size,
29};
30
31pub async fn run_tui(agent_command: &str) -> Result<(), AppError> {
36 setup_logging(None);
37 let state = RuntimeState::new(agent_command).await?;
38 run_with_state(state).await
39}
40
41pub async fn run_with_state(state: RuntimeState) -> Result<(), AppError> {
43 let RuntimeState {
44 session_id,
45 agent_name,
46 prompt_capabilities,
47 config_options,
48 auth_methods,
49 theme,
50 event_rx,
51 prompt_handle,
52 working_dir,
53 } = state;
54
55 let app = App::new(
56 session_id,
57 agent_name,
58 prompt_capabilities,
59 &config_options,
60 auth_methods,
61 working_dir,
62 prompt_handle,
63 );
64
65 run_app(app, theme, event_rx).await
66}
67
68pub fn setup_logging(log_dir: Option<&str>) {
69 let dir = log_dir.unwrap_or("/tmp/wisp-logs");
70 create_dir_all(dir).ok();
71 tracing_subscriber::fmt()
72 .with_writer(daily(dir, "wisp.log"))
73 .with_ansi(false)
74 .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
75 .init();
76}
77
78fn render(renderer: &mut Renderer<impl io::Write>, app: &mut App) -> Result<(), AppError> {
79 renderer.render_frame(|ctx| app.render(ctx))?;
80 Ok(())
81}
82
83async fn run_app(
84 mut app: App,
85 theme: tui::Theme,
86 mut event_rx: mpsc::UnboundedReceiver<acp_utils::client::AcpEvent>,
87) -> Result<(), AppError> {
88 let size = terminal_size().unwrap_or((80, 24));
89 let mut renderer = Renderer::new(io::stdout(), theme, size);
90 let _session = TerminalSession::new(true, MouseCapture::Disabled)?;
91 let mut terminal_rx = spawn_terminal_event_task();
92 let mut tick_interval = {
93 let mut tick = interval(Duration::from_millis(100));
94 tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
95 tick
96 };
97
98 let mut last_mouse_capture = false;
99 render(&mut renderer, &mut app)?;
100 loop {
101 let tick_fut = async {
102 if !app.wants_tick() {
103 pending::<()>().await;
104 }
105 tick_interval.tick().await;
106 };
107
108 select! {
109 terminal_event = terminal_rx.recv() => {
110 let Some(event) = terminal_event else {
111 return Ok(());
112 };
113 if let CrosstermEvent::Resize(cols, rows) = &event {
114 renderer.on_resize((*cols, *rows));
115 }
116 if let Ok(tui_event) = Event::try_from(event) {
117 let commands = app.on_event(&tui_event).await.unwrap_or_default();
118 renderer.apply_commands(commands)?;
119 if app.exit_requested() { return Ok(()); }
120 render(&mut renderer, &mut app)?;
121 }
122 }
123
124 app_event = event_rx.recv() => {
125 match app_event {
126 Some(event) => {
127 app.on_acp_event(event);
128 if app.exit_requested() { return Ok(()); }
129 render(&mut renderer, &mut app)?;
130 }
131 None => return Ok(()),
132 }
133 }
134
135 () = tick_fut => {
136 app.on_event(&Event::Tick).await;
137 if app.exit_requested() { return Ok(()); }
138 render(&mut renderer, &mut app)?;
139 }
140 }
141
142 let capture = app.needs_mouse_capture();
143 if last_mouse_capture != capture {
144 renderer.apply_commands(vec![RendererCommand::SetMouseCapture(capture)])?;
145 last_mouse_capture = capture;
146 }
147 }
148}