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