Skip to main content

wisp/
lib.rs

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
31/// Launch the wisp TUI with the given agent subprocess command.
32///
33/// Sets up logging, connects to the agent via ACP, and runs the interactive
34/// terminal event loop until the user exits.
35pub 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
41/// Run the TUI from an already-initialized [`RuntimeState`].
42pub 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(
75            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
76        )
77        .init();
78}
79
80fn render(renderer: &mut Renderer<impl io::Write>, app: &mut App) -> Result<(), AppError> {
81    renderer.render_frame(|ctx| app.render(ctx))?;
82    Ok(())
83}
84
85async fn run_app(
86    mut app: App,
87    theme: tui::Theme,
88    mut event_rx: mpsc::UnboundedReceiver<acp_utils::client::AcpEvent>,
89) -> Result<(), AppError> {
90    let size = terminal_size().unwrap_or((80, 24));
91    let mut renderer = Renderer::new(io::stdout(), theme, size);
92    let _session = TerminalSession::new(true, MouseCapture::Disabled)?;
93    let mut terminal_rx = spawn_terminal_event_task();
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 renderer, &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_rx.recv() => {
112                let Some(event) = terminal_event else {
113                    return Ok(());
114                };
115                if let CrosstermEvent::Resize(cols, rows) = &event {
116                    renderer.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                    renderer.apply_commands(commands)?;
121                    if app.exit_requested() { return Ok(()); }
122                    render(&mut renderer, &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 renderer, &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 renderer, &mut app)?;
141            }
142        }
143
144        let capture = app.needs_mouse_capture();
145        if last_mouse_capture != capture {
146            renderer.apply_commands(vec![RendererCommand::SetMouseCapture(capture)])?;
147            last_mouse_capture = capture;
148        }
149    }
150}