Skip to main content

wisp/app/
mod.rs

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