Skip to main content

wisp/app/
mod.rs

1use crate::Session;
2use crate::app::keybindings::Keybindings;
3use crate::app::message::Message;
4use crate::command::{AgentCommand, Command, CommandResult};
5use crate::conversation::plan_tracker::PlanTracker;
6use crate::conversation::progress_indicator::{ProgressIndicator, ProgressPhase};
7use crate::conversation::status_line::{ContextUsageDisplay, StatusLineModel};
8use crate::request::RequestId;
9use crate::session::WorkspaceAccess;
10use crate::session::platform::{BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer};
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::settings::overlay::ProviderLoginStatus;
18use crate::surfaces::composer::{Composer, Submission};
19use crate::surfaces::picker::CommandEntry;
20use crate::surfaces::workspace_picker::WorkspacePicker;
21use crate::theme::Theme;
22use crate::view::generation::Generation;
23use acp_utils::client::AcpEvent;
24use acp_utils::conversation::{Conversation, ConversationId, ConversationItem};
25use acp_utils::notifications::AetherCapabilities;
26use agent_client_protocol::schema::v2::PlanEntry;
27use agent_client_protocol::schema::v2::{self as acp};
28use std::collections::VecDeque;
29use std::path::PathBuf;
30use std::time::Instant;
31use tokio::sync::mpsc;
32
33pub mod message;
34mod navigation;
35
36pub use navigation::{Overlay, Route};
37
38mod acp_reducer;
39mod config;
40mod foreground;
41mod input;
42mod keybindings;
43mod session;
44mod submission;
45use config::build_theme_entries;
46pub use foreground::ForegroundOperation;
47use input::CTRL_C_CONFIRM_WINDOW;
48use session::builtin_commands;
49pub use submission::QueuedPrompt;
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub enum ExitState {
53    #[default]
54    Idle,
55    Confirming(Instant),
56    Exiting,
57    ConnectionLost,
58}
59
60impl ExitState {
61    fn is_confirming(&self) -> bool {
62        matches!(self, ExitState::Confirming(_))
63    }
64}
65
66/// Root UI state: reduces terminal input and ACP events into the canonical
67/// conversation, feature state, and composer that the renderer draws each frame.
68pub struct App {
69    session: SessionModel,
70    ui: UiConfig,
71    available_commands: Vec<CommandEntry>,
72    route: Route,
73    overlay: Option<Overlay>,
74    conversation: Conversation,
75    plan_tracker: PlanTracker,
76    progress: ProgressIndicator,
77    spinner_tick: usize,
78    composer: Composer,
79    exit_state: ExitState,
80    swallow_next_click: bool,
81    /// What the event loop still owes the outside world.
82    commands: VecDeque<Command>,
83    foreground: ForegroundOperation,
84    queued_prompts: Vec<QueuedPrompt>,
85    browser_opener: BrowserOpener,
86    clipboard_writer: ClipboardWriter,
87}
88
89/// How the UI is configured, as opposed to what it is currently showing.
90struct UiConfig {
91    settings: SettingsModel,
92    keybindings: Keybindings,
93    content_padding: usize,
94    status_line: ResolvedStatusLineSettings,
95    theme: Theme,
96    theme_generation: Generation,
97}
98
99pub struct AppConfig {
100    pub initialize_response: acp::InitializeResponse,
101    pub session_response: acp::NewSessionResponse,
102    pub workspace_status: WorkspaceStatus,
103    pub workspace_access: WorkspaceAccess,
104    pub working_dir: PathBuf,
105    pub settings: UiSettings,
106    /// Host services the UI reaches for; injected so tests observe URL opens
107    /// and clipboard writes without spawning anything.
108    pub browser_opener: BrowserOpener,
109    pub clipboard_writer: ClipboardWriter,
110}
111
112impl App {
113    /// Build the UI from a freshly connected ACP session.
114    ///
115    /// Returns the pieces only the process outside the UI needs: the event
116    /// channel feeding the event loop and the handle commands are sent through.
117    /// Crate-internal entry-point wiring, not part of the public or test API.
118    pub(crate) fn from_session(
119        session: crate::session::Session,
120        settings: UiSettings,
121    ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpClientHandle) {
122        let Session { client, response, working_dir, workspace_status, workspace_access } = session;
123        let mut app = Self::new(AppConfig {
124            initialize_response: client.initialize_response,
125            session_response: response,
126            workspace_status,
127            workspace_access,
128            working_dir,
129            settings,
130            browser_opener: default_browser_opener(),
131            clipboard_writer: default_clipboard_writer(),
132        });
133        app.resolve_workspace(app.session.working_dir().to_path_buf());
134        (app, client.event_rx, client.handle)
135    }
136
137    pub fn new(config: AppConfig) -> Self {
138        let (theme, theme_error) = match Theme::load_selection(&config.settings.theme) {
139            Ok(theme) => (theme, None),
140            Err(error) => (Theme::default(), Some(error)),
141        };
142        let ui = UiConfig {
143            content_padding: resolve_content_padding(&config.settings),
144            status_line: resolve_status_line_settings(&config.settings),
145            keybindings: Keybindings::from_settings(&config.settings),
146            theme,
147            theme_generation: Generation::default(),
148            settings: SettingsModel::new(config.settings.clone()),
149        };
150        let capabilities = AetherCapabilities::from_meta(
151            config.initialize_response.capabilities.session.as_ref().and_then(|session| session.meta.as_ref()),
152        );
153        let initial_commands = builtin_commands(&capabilities);
154        let browser_opener = config.browser_opener.clone();
155        let clipboard_writer = config.clipboard_writer.clone();
156        let mut app = 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::new(),
163            plan_tracker: PlanTracker::default(),
164            progress: ProgressIndicator::default(),
165            spinner_tick: 0,
166            composer: Composer::new(),
167            exit_state: ExitState::Idle,
168            swallow_next_click: false,
169            commands: VecDeque::new(),
170            foreground: ForegroundOperation::Idle,
171            queued_prompts: Vec::new(),
172            browser_opener,
173            clipboard_writer,
174        };
175        if let Some(error) = theme_error {
176            app.notify(&format!("Could not load selected theme: {error}"));
177        }
178        app
179    }
180
181    /// Reduce one external input and return its commands.
182    ///
183    /// This is the synchronous model boundary used by the runtime dispatcher.
184    pub fn update(&mut self, message: Message) -> Vec<Command> {
185        match message {
186            Message::Terminal(event) => self.on_terminal_event(event),
187            Message::Agent(event) => self.on_acp_event(*event),
188            Message::CommandFinished(result) => self.on_command_result(*result),
189            Message::Tick(now) => self.on_tick(now),
190        }
191
192        self.plan_tracker.sync(self.conversation.plan(), Instant::now());
193        self.refresh_progress();
194        self.take_commands()
195    }
196
197    pub fn take_commands(&mut self) -> Vec<Command> {
198        self.commands.drain(..).collect()
199    }
200
201    #[allow(clippy::too_many_lines)]
202    pub fn on_command_result(&mut self, result: CommandResult) {
203        match result {
204            CommandResult::Prompt { request_id, result } => self.finish_prompt(request_id, result),
205            CommandResult::Cancel(result) | CommandResult::AuthenticateMcp(result) => {
206                if let Err(error) = result {
207                    self.notify(&error);
208                }
209            }
210            CommandResult::NewSession(result) => match result {
211                Ok(response) => self.on_new_session(response.session_id, response.config_options),
212                Err(error) => {
213                    self.foreground = ForegroundOperation::Idle;
214                    self.notify(&format!("Failed to create new session: {error}"));
215                }
216            },
217            CommandResult::ResumeSession { session_id, result } => {
218                if !matches!(&self.foreground,
219                    ForegroundOperation::ResumingSession { session_id: expected, .. }
220                    | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, .. } if expected == &session_id)
221                {
222                    return;
223                }
224                match result {
225                    Ok(response) => self.on_resumed_session(&session_id, response),
226                    Err(error) => {
227                        self.foreground = ForegroundOperation::Idle;
228                        self.notify(&format!("Failed to resume session: {error}"));
229                    }
230                }
231            }
232            CommandResult::ConfigOptionsUpdated { conversation_id, result: Ok(response) } => {
233                if conversation_id != self.conversation_id() {
234                    return;
235                }
236                self.session.update_config_options(response.config_options);
237                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
238                    overlay.update_config_options(self.session.config_options());
239                }
240            }
241            CommandResult::ConfigOptionsUpdated { conversation_id, result: Err(error) } => {
242                if conversation_id != self.conversation_id() {
243                    return;
244                }
245                tracing::warn!("set_session_config_option failed: {error}");
246                self.notify(&format!("Failed to update setting: {error}"));
247            }
248            CommandResult::AuthenticationCompleted { method_id, result: Ok(_) } => {
249                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
250                    overlay.set_provider_status(&method_id, ProviderLoginStatus::LoggedIn);
251                }
252            }
253            CommandResult::AuthenticationCompleted { method_id, result: Err(error) } => {
254                tracing::warn!("Provider authentication failed for {method_id}: {error}");
255                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
256                    overlay.set_provider_status(&method_id, ProviderLoginStatus::NeedsLogin);
257                }
258            }
259            CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
260            CommandResult::GitReview(state) => {
261                if let Route::GitReview(screen) = &mut self.route {
262                    screen.install(&state);
263                }
264            }
265            CommandResult::GitReviewAction(result) => {
266                if let Route::GitReview(screen) = &mut self.route {
267                    screen.on_action_result(result);
268                }
269            }
270            CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
271            CommandResult::ThemesListed(files) => {
272                let entries = build_theme_entries(self.ui.settings.ui(), &files);
273                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
274                    overlay.upsert_local_entries(entries);
275                }
276            }
277            CommandResult::ReviewThemesListed(choices) => match &mut self.route {
278                Route::GitReview(screen) => screen.set_theme_choices(choices),
279                Route::ArtifactReview(screen) => screen.set_theme_choices(choices),
280                Route::Conversation => {}
281            },
282            CommandResult::ThemeApplied(result) => self.finish_theme_change(result),
283            CommandResult::WorkspaceResolved { cwd, status } => {
284                if self.session.working_dir() == cwd {
285                    self.session.set_workspace_status(status);
286                }
287            }
288            CommandResult::SessionsListed(Ok(response)) => self.open_session_picker(response.sessions),
289            CommandResult::SessionsListed(Err(error)) => self.notify(&format!("Failed to list sessions: {error}")),
290            CommandResult::PromptSearchResults { result: Ok(response), .. } => {
291                self.composer.prompt_search_on_results(response);
292            }
293            CommandResult::PromptSearchResults { query, result: Err(error) } => {
294                if let Some(picker) = self.composer.prompt_search_mut() {
295                    picker.on_failed(&query, error);
296                }
297            }
298            CommandResult::SessionPreviewLoaded { result: Ok(preview), .. } => {
299                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
300                    picker.on_preview_loaded(preview);
301                }
302            }
303            CommandResult::SessionPreviewLoaded { session_id, result: Err(error) } => {
304                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
305                    picker.on_preview_failed(&session_id, error);
306                }
307            }
308            CommandResult::WorkspacesListed(Ok(response)) => {
309                self.open_overlay(Overlay::Workspaces(WorkspacePicker::new(response.workspaces)));
310                self.foreground = ForegroundOperation::PickingWorkspace;
311            }
312            CommandResult::WorkspacesListed(Err(error)) => {
313                self.abandon_workspace_move(&format!("Failed to list workspaces: {error}"));
314            }
315            CommandResult::WorkspaceMoved(Ok(response)) => self.on_workspace_moved(response.new_cwd),
316            CommandResult::WorkspaceMoved(Err(error)) => {
317                self.abandon_workspace_move(&format!("Workspace move failed: {error}"));
318            }
319            CommandResult::BackgroundFailed(error) | CommandResult::TerminalFailed(error) => self.notify(&error),
320        }
321    }
322
323    fn start_prompt(&mut self, submission: Submission, content: Option<Vec<acp::ContentBlock>>) {
324        let request_id = RequestId::next();
325        let text = submission.text.clone();
326        self.queued_prompts.push(QueuedPrompt { request_id, submission });
327        self.queue(Command::Agent(AgentCommand::Prompt {
328            request_id,
329            session_id: self.session.session_id().clone(),
330            text,
331            content,
332        }));
333    }
334
335    fn queue(&mut self, command: Command) {
336        self.commands.push_back(command);
337    }
338
339    /// Queues a config change for the agent and session the app is attached to.
340    fn set_config_option(&mut self, config_id: &str, value: &str) {
341        self.queue(Command::Agent(AgentCommand::SetConfigOption {
342            conversation_id: self.conversation_id(),
343            session_id: self.session.session_id().clone(),
344            config_id: config_id.to_string(),
345            value: value.into(),
346        }));
347    }
348
349    pub fn on_tick(&mut self, now: Instant) {
350        if let ExitState::Confirming(armed_at) = self.exit_state
351            && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
352        {
353            self.exit_state = ExitState::Idle;
354        }
355        if self.progress.is_active() {
356            self.spinner_tick = self.spinner_tick.wrapping_add(1);
357        }
358        self.progress.on_tick(now);
359        self.plan_tracker.on_tick(now);
360    }
361
362    pub fn wants_tick(&self) -> bool {
363        self.waiting_for_response()
364            || matches!(
365                self.foreground,
366                ForegroundOperation::ListingWorkspaces
367                    | ForegroundOperation::PickingWorkspace
368                    | ForegroundOperation::MovingWorkspace
369                    | ForegroundOperation::LoadingWorkspaceSession { .. }
370            )
371            || self.conversation.any_running()
372            || self.conversation.is_compacting()
373            || self.progress.is_active()
374            || self.exit_state.is_confirming()
375            || self.plan_tracker.has_completed_in_grace_period()
376    }
377
378    pub fn has_navigation(&self) -> bool {
379        self.overlay.is_some() || self.route.is_fullscreen()
380    }
381
382    pub fn has_session_picker(&self) -> bool {
383        matches!(self.overlay, Some(Overlay::Sessions(_)))
384    }
385
386    pub fn has_modal(&self) -> bool {
387        self.overlay.is_some()
388    }
389
390    pub fn full_screen_active(&self) -> bool {
391        self.route.is_fullscreen()
392    }
393
394    pub fn foreground_operation(&self) -> &ForegroundOperation {
395        &self.foreground
396    }
397
398    pub fn exit_requested(&self) -> bool {
399        self.exit_result().is_some()
400    }
401
402    pub fn exit_result(&self) -> Option<Result<(), crate::error::AppError>> {
403        match self.exit_state {
404            ExitState::Exiting => Some(Ok(())),
405            ExitState::ConnectionLost => Some(Err(crate::error::AppError::ConnectionLost)),
406            ExitState::Idle | ExitState::Confirming(_) => None,
407        }
408    }
409
410    pub fn session_id(&self) -> &acp::SessionId {
411        self.session.session_id()
412    }
413
414    pub fn conversation_items(&self) -> &[ConversationItem] {
415        self.conversation.items()
416    }
417
418    pub fn conversation_id(&self) -> ConversationId {
419        self.conversation.id()
420    }
421
422    pub fn composer(&self) -> &Composer {
423        &self.composer
424    }
425
426    pub(crate) fn composer_mut(&mut self) -> &mut Composer {
427        &mut self.composer
428    }
429
430    pub fn config_options(&self) -> &[LocalConfigOption] {
431        self.session.config_options()
432    }
433
434    pub fn auth_methods(&self) -> &[acp::AuthMethod] {
435        self.session.auth_methods()
436    }
437
438    pub(crate) fn content_padding(&self) -> usize {
439        self.ui.content_padding
440    }
441
442    /// Everything the status line reads, gathered for one frame.
443    pub fn status_line_model(&self) -> StatusLineModel<'_> {
444        StatusLineModel {
445            settings: &self.ui.status_line,
446            config_options: self.session.config_options(),
447            workspace: self.session.workspace_status(),
448            agent_name: self.session.agent_name(),
449            content_padding: self.ui.content_padding,
450            context_usage: self.conversation.context_usage().map(ContextUsageDisplay::from),
451            unhealthy_servers: self.session.unhealthy_server_count(),
452            waiting_for_response: self.waiting_for_response(),
453            exit_confirmation: self.exit_state.is_confirming(),
454        }
455    }
456
457    pub fn ui_settings(&self) -> &UiSettings {
458        self.ui.settings.ui()
459    }
460
461    /// The active theme; the renderer resyncs when `theme_generation` moves.
462    pub fn theme(&self) -> &Theme {
463        &self.ui.theme
464    }
465
466    pub(crate) fn theme_generation(&self) -> Generation {
467        self.ui.theme_generation
468    }
469
470    pub fn waiting_for_response(&self) -> bool {
471        !self.conversation.turn().is_idle() || !self.queued_prompts.is_empty()
472    }
473
474    pub fn queued_prompts(&self) -> &[QueuedPrompt] {
475        &self.queued_prompts
476    }
477
478    /// Either the prompt or one of its tool calls is still running.
479    pub fn is_agent_busy(&self) -> bool {
480        self.waiting_for_response() || self.conversation.any_running()
481    }
482
483    pub fn progress_indicator(&self) -> &ProgressIndicator {
484        &self.progress
485    }
486
487    /// Test seam: the status line reads this through
488    /// [`App::status_line_model`] rather than calling it.
489    pub fn exit_confirmation_active(&self) -> bool {
490        self.exit_state.is_confirming()
491    }
492
493    pub(crate) fn spinner_tick(&self) -> usize {
494        self.spinner_tick
495    }
496
497    pub fn plan_entries(&self) -> Vec<PlanEntry> {
498        self.plan_tracker.current_entries()
499    }
500
501    /// Reaches past the renderer for the integration tests, which assert on the
502    /// state a frame is drawn from rather than on the frame.
503    pub fn has_plan(&self) -> bool {
504        self.plan_tracker.has_entries()
505    }
506
507    /// Drops all conversation state atomically before starting a new session.
508    fn reset_conversation(&mut self) {
509        self.conversation.clear();
510        self.foreground.drop_prepared_prompt();
511    }
512
513    fn refresh_progress(&mut self) {
514        let override_phase = match self.foreground {
515            ForegroundOperation::MovingWorkspace => Some(ProgressPhase::MovingWorkspace),
516            ForegroundOperation::LoadingWorkspaceSession { .. } => Some(ProgressPhase::LoadingSession),
517            _ if self.conversation.is_compacting() => Some(ProgressPhase::Compacting),
518            _ => None,
519        };
520        let interruptible = self.is_agent_busy();
521        self.progress.refresh(&self.conversation, override_phase, interruptible);
522    }
523
524    fn return_to_conversation(&mut self) {
525        self.open_route(Route::Conversation);
526    }
527}