Skip to main content

wisp/app/
mod.rs

1use crate::command::{Command, CommandResult, FailedCommand};
2use crate::app::keybindings::Keybindings;
3use crate::app::message::Message;
4use crate::conversation::items::{Conversation, ConversationItem};
5use crate::conversation::progress_indicator::{ProgressIndicator, ProgressPhase};
6use crate::conversation::status_line::StatusLineModel;
7use crate::session::platform::{
8    BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer,
9};
10use crate::session::session_config_view::LocalConfigOption;
11use crate::session::session_model::SessionModel;
12use crate::session::workspace_status::WorkspaceStatus;
13use crate::settings::{
14    ResolvedStatusLineSettings, SettingsModel, UiSettings, resolve_content_padding, resolve_status_line_settings,
15};
16use crate::surfaces::composer::Composer;
17use crate::surfaces::input::RootOutput;
18use crate::surfaces::picker::CommandEntry;
19use crate::view::generation::Generation;
20use crate::theme::Theme;
21use acp_utils::client::AcpEvent;
22use acp_utils::notifications::AetherCapabilities;
23use agent_client_protocol::schema::v1::{self as acp, SessionId};
24use std::collections::VecDeque;
25use std::path::PathBuf;
26use std::time::Instant;
27use tokio::sync::mpsc;
28
29pub mod message;
30mod navigation;
31
32pub use crate::session::session_model::WorkspaceMoveState;
33pub use navigation::{Overlay, Route};
34
35mod acp_reducer;
36mod config;
37mod input;
38mod keybindings;
39mod session;
40mod submission;
41use config::build_theme_entries;
42use input::CTRL_C_CONFIRM_WINDOW;
43use session::builtin_commands;
44use submission::SubmissionState;
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum ExitState {
48    #[default]
49    Idle,
50    Confirming(Instant),
51    Exiting,
52}
53
54impl ExitState {
55    fn is_confirming(&self) -> bool {
56        matches!(self, ExitState::Confirming(_))
57    }
58}
59
60/// Root UI state: reduces terminal input and ACP events into the canonical
61/// conversation, feature state, and composer that the renderer draws each frame.
62pub struct App {
63    session: SessionModel,
64    ui: UiConfig,
65    available_commands: Vec<CommandEntry>,
66    route: Route,
67    overlay: Option<Overlay>,
68    conversation: Conversation,
69    composer: Composer,
70    exit_state: ExitState,
71    /// What the event loop still owes the outside world.
72    commands: VecDeque<Command>,
73    submission: SubmissionState,
74    browser_opener: BrowserOpener,
75    clipboard_writer: ClipboardWriter,
76}
77
78/// How the UI is configured, as opposed to what it is currently showing.
79struct UiConfig {
80    settings: SettingsModel,
81    keybindings: Keybindings,
82    content_padding: usize,
83    status_line: ResolvedStatusLineSettings,
84    theme: Theme,
85    theme_generation: Generation,
86}
87
88pub struct AppConfig {
89    pub session_id: SessionId,
90    pub agent_name: String,
91    pub workspace_status: WorkspaceStatus,
92    pub prompt_capabilities: acp::PromptCapabilities,
93    pub session_capabilities: acp::SessionCapabilities,
94    pub config_options: Vec<acp::SessionConfigOption>,
95    pub auth_methods: Vec<acp::AuthMethod>,
96    pub working_dir: PathBuf,
97    pub settings: UiSettings,
98    /// Host services the UI reaches for; injected so tests observe URL opens
99    /// and clipboard writes without spawning anything.
100    pub browser_opener: BrowserOpener,
101    pub clipboard_writer: ClipboardWriter,
102}
103
104impl App {
105    /// Build the UI from a freshly connected ACP session.
106    ///
107    /// Returns the pieces only the process outside the UI needs: the event
108    /// channel feeding the event loop and the handle commands are sent through.
109    /// Crate-internal entry-point wiring, not part of the public or test API.
110    pub(crate) fn from_session(
111        session: crate::session::Session,
112        settings: UiSettings,
113    ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpPromptHandle) {
114        let crate::session::Session {
115            session_id,
116            agent_name,
117            prompt_capabilities,
118            session_capabilities,
119            config_options,
120            auth_methods,
121            event_rx,
122            prompt_handle,
123            working_dir,
124            workspace_status,
125        } = session;
126        let mut app = Self::new(AppConfig {
127            session_id,
128            agent_name,
129            workspace_status,
130            prompt_capabilities,
131            session_capabilities,
132            config_options,
133            auth_methods,
134            working_dir,
135            settings,
136            browser_opener: default_browser_opener(),
137            clipboard_writer: default_clipboard_writer(),
138        });
139        app.queue(Command::ResolveWorkspace { cwd: app.session.working_dir().to_path_buf() });
140        (app, event_rx, prompt_handle)
141    }
142
143    pub fn new(config: AppConfig) -> Self {
144        let ui = UiConfig {
145            content_padding: resolve_content_padding(&config.settings),
146            status_line: resolve_status_line_settings(&config.settings),
147            keybindings: Keybindings::from_settings(&config.settings),
148            theme: Theme::load(&config.settings),
149            theme_generation: Generation::default(),
150            settings: SettingsModel::new(config.settings.clone()),
151        };
152        let capabilities = AetherCapabilities::from_meta(config.session_capabilities.meta.as_ref());
153        let initial_commands = builtin_commands(&capabilities);
154        let browser_opener = config.browser_opener.clone();
155        let clipboard_writer = config.clipboard_writer.clone();
156        Self {
157            session: SessionModel::from_config(config, capabilities),
158            ui,
159            available_commands: initial_commands,
160            route: Route::Conversation,
161            overlay: None,
162            conversation: Conversation::default(),
163            composer: Composer::new(),
164            exit_state: ExitState::Idle,
165            commands: VecDeque::new(),
166            submission: SubmissionState::default(),
167            browser_opener,
168            clipboard_writer,
169        }
170    }
171
172    /// Reduce one external input and return its commands.
173    ///
174    /// This is the synchronous model boundary used by the runtime dispatcher.
175    pub fn update(&mut self, message: Message) -> Vec<Command> {
176        match message {
177            Message::Terminal(event) => self.on_terminal_event(event),
178            Message::Agent(event) => self.on_acp_event(*event),
179            Message::CommandFinished(result) => self.on_command_result(result),
180            Message::Tick(now) => self.on_tick(now),
181        }
182
183        self.refresh_progress();
184        self.take_commands()
185    }
186
187    pub fn take_commands(&mut self) -> Vec<Command> {
188        self.commands.drain(..).collect()
189    }
190
191    pub fn on_command_result(&mut self, result: CommandResult) {
192        match result {
193            CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
194            CommandResult::GitDiff(event) => {
195                let Route::GitReview(screen) = &mut self.route else { return };
196                let outputs = screen.on_event(event).into_iter().map(RootOutput::GitReview).collect();
197                self.dispatch_outputs(outputs);
198            }
199            CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
200            CommandResult::ThemesListed(files) => {
201                let entries = build_theme_entries(self.ui.settings.ui(), &files);
202                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
203                    overlay.upsert_local_entries(entries);
204                }
205            }
206            CommandResult::ThemeApplied { settings, theme, error } => self.finish_theme_change(settings, theme, error),
207            CommandResult::WorkspaceResolved { cwd, status } => {
208                if self.session.working_dir() == cwd {
209                    self.session.set_workspace_status(status);
210                }
211            }
212            CommandResult::PromptSearchFailed { query, error } => {
213                if let Some(picker) = self.composer.prompt_search_mut() {
214                    picker.on_failed(&query, error);
215                }
216            }
217            CommandResult::Failed { command, error } => self.on_command_failed(command, &error),
218        }
219    }
220
221    fn on_command_failed(&mut self, command: FailedCommand, error: &str) {
222        match command {
223            FailedCommand::Prompt => {
224                self.conversation.turn_mut().set_prompt_in_flight(false);
225                self.conversation.progress_indicator_mut().prompt_finished();
226                self.submission.reset();
227            }
228            FailedCommand::LoadSession => {
229                self.session.clear_loads();
230                self.session.end_workspace_move();
231            }
232            FailedCommand::ListWorkspaces | FailedCommand::MoveWorkspace => {
233                self.session.end_workspace_move();
234            }
235            FailedCommand::Other(_) => {}
236        }
237        self.notify(&format!("Failed to {}: {error}", command.describe()));
238    }
239
240    fn queue(&mut self, command: Command) {
241        self.commands.push_back(command);
242    }
243
244    pub fn on_tick(&mut self, now: Instant) {
245        if let ExitState::Confirming(armed_at) = self.exit_state
246            && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
247        {
248            self.exit_state = ExitState::Idle;
249        }
250        if self.conversation.progress_indicator().is_active() {
251            self.conversation.turn_mut().advance_spinner();
252        }
253        self.conversation.progress_indicator_mut().on_tick(now);
254        self.conversation.plan_tracker_mut().on_tick(now);
255    }
256
257    pub fn wants_tick(&self) -> bool {
258        self.waiting_for_response()
259            || self.conversation.any_running()
260            || !self.session.workspace_move_state().is_idle()
261            || self.conversation.turn().is_compaction_active()
262            || self.conversation.progress_indicator().is_active()
263            || self.exit_state.is_confirming()
264            || self.conversation.plan_tracker().has_completed_in_grace_period()
265    }
266
267    pub fn has_navigation(&self) -> bool {
268        self.overlay.is_some() || self.route.is_fullscreen()
269    }
270
271    pub fn has_session_picker(&self) -> bool {
272        matches!(self.overlay, Some(Overlay::Sessions(_)))
273    }
274
275    pub fn has_modal(&self) -> bool {
276        self.overlay.is_some()
277    }
278
279    pub fn full_screen_active(&self) -> bool {
280        self.route.is_fullscreen()
281    }
282
283    pub fn workspace_move_state(&self) -> WorkspaceMoveState {
284        self.session.workspace_move_state()
285    }
286
287    pub fn exit_requested(&self) -> bool {
288        self.exit_state == ExitState::Exiting
289    }
290
291    pub fn conversation_items(&self) -> &[ConversationItem] {
292        self.conversation.items()
293    }
294
295    pub fn conversation_id(&self) -> crate::conversation::ConversationId {
296        self.conversation.id()
297    }
298
299    pub fn composer(&self) -> &Composer {
300        &self.composer
301    }
302
303    pub(crate) fn composer_mut(&mut self) -> &mut Composer {
304        &mut self.composer
305    }
306
307    pub fn config_options(&self) -> &[LocalConfigOption] {
308        self.session.config_options()
309    }
310
311    pub fn auth_methods(&self) -> &[acp::AuthMethod] {
312        self.session.auth_methods()
313    }
314
315    pub(crate) fn content_padding(&self) -> usize {
316        self.ui.content_padding
317    }
318
319    /// Everything the status line reads, gathered for one frame.
320    pub fn status_line_model(&self) -> StatusLineModel<'_> {
321        StatusLineModel {
322            settings: &self.ui.status_line,
323            config_options: self.session.config_options(),
324            workspace: self.session.workspace_status(),
325            agent_name: self.session.agent_name(),
326            content_padding: self.ui.content_padding,
327            context_usage: self.conversation.turn().context_usage(),
328            unhealthy_servers: self.session.unhealthy_server_count(),
329            waiting_for_response: self.waiting_for_response(),
330            exit_confirmation: self.exit_state.is_confirming(),
331        }
332    }
333
334    pub fn ui_settings(&self) -> &UiSettings {
335        self.ui.settings.ui()
336    }
337
338    /// The active theme; the renderer resyncs when `theme_generation` moves.
339    pub fn theme(&self) -> &Theme {
340        &self.ui.theme
341    }
342
343    pub(crate) fn theme_generation(&self) -> Generation {
344        self.ui.theme_generation
345    }
346
347    /// A prompt is outstanding, so the agent owes us a reply.
348    pub fn waiting_for_response(&self) -> bool {
349        self.conversation.turn().is_prompt_in_flight()
350    }
351
352    /// Either the prompt or one of its tool calls is still running.
353    pub fn is_agent_busy(&self) -> bool {
354        self.waiting_for_response() || self.conversation.any_running()
355    }
356
357    pub fn progress_indicator(&self) -> &ProgressIndicator {
358        self.conversation.progress_indicator()
359    }
360
361    /// Test seam: the status line reads this through
362    /// [`App::status_line_model`] rather than calling it.
363    pub fn exit_confirmation_active(&self) -> bool {
364        self.exit_state.is_confirming()
365    }
366
367    pub(crate) fn spinner_tick(&self) -> usize {
368        self.conversation.turn().spinner_tick()
369    }
370
371    pub fn plan_entries(&self) -> Vec<acp::PlanEntry> {
372        self.conversation.plan_tracker().current_entries()
373    }
374
375    /// Reaches past the renderer for the integration tests, which assert on the
376    /// state a frame is drawn from rather than on the frame.
377    pub fn has_plan(&self) -> bool {
378        self.conversation.plan_tracker().has_entries()
379    }
380
381    /// Drops all conversation state atomically before starting a new session.
382    fn reset_conversation(&mut self) {
383        self.reset_turn_state();
384        self.submission.reset();
385        self.conversation.clear();
386    }
387
388    /// Clears the per-turn indicators that must not survive into a different
389    /// conversation. Used on its own when a load lands, because the conversation
390    /// was already cleared when that load was requested — and may since have
391    /// gained notices the user still needs to see.
392    fn reset_turn_state(&mut self) {
393        // The spinner phase is cosmetic and survives, so a swap does not make
394        // the indicator visibly jump.
395        self.conversation.reset_feature_state();
396    }
397
398    fn refresh_progress(&mut self) {
399        let override_phase = match self.session.workspace_move_state() {
400            WorkspaceMoveState::Moving => Some(ProgressPhase::MovingWorkspace),
401            WorkspaceMoveState::LoadingSession => Some(ProgressPhase::LoadingSession),
402            _ if self.conversation.turn().is_compaction_active() => Some(ProgressPhase::Compacting),
403            _ => None,
404        };
405        let interruptible = self.is_agent_busy();
406        self.conversation.progress_indicator_mut().refresh(override_phase, interruptible);
407    }
408
409    fn return_to_conversation(&mut self) {
410        self.close_overlay();
411        self.route = Route::Conversation;
412    }
413}