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