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::session::WorkspaceAccess;
9use crate::session::platform::{BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer};
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::picker::CommandEntry;
18use crate::surfaces::workspace_picker::WorkspacePicker;
19use crate::theme::Theme;
20use crate::view::generation::Generation;
21use acp_utils::client::AcpEvent;
22use acp_utils::conversation::{Conversation, ConversationId, ConversationItem};
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;
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    plan_tracker: PlanTracker,
73    progress: ProgressIndicator,
74    spinner_tick: usize,
75    composer: Composer,
76    exit_state: ExitState,
77    /// What the event loop still owes the outside world.
78    commands: VecDeque<Command>,
79    foreground: ForegroundOperation,
80    browser_opener: BrowserOpener,
81    clipboard_writer: ClipboardWriter,
82}
83
84/// How the UI is configured, as opposed to what it is currently showing.
85struct UiConfig {
86    settings: SettingsModel,
87    keybindings: Keybindings,
88    content_padding: usize,
89    status_line: ResolvedStatusLineSettings,
90    theme: Theme,
91    theme_generation: Generation,
92}
93
94pub struct AppConfig {
95    pub initialize_response: acp::InitializeResponse,
96    pub session_response: acp::NewSessionResponse,
97    pub workspace_status: WorkspaceStatus,
98    pub workspace_access: WorkspaceAccess,
99    pub working_dir: PathBuf,
100    pub settings: UiSettings,
101    /// Host services the UI reaches for; injected so tests observe URL opens
102    /// and clipboard writes without spawning anything.
103    pub browser_opener: BrowserOpener,
104    pub clipboard_writer: ClipboardWriter,
105}
106
107impl App {
108    /// Build the UI from a freshly connected ACP session.
109    ///
110    /// Returns the pieces only the process outside the UI needs: the event
111    /// channel feeding the event loop and the handle commands are sent through.
112    /// Crate-internal entry-point wiring, not part of the public or test API.
113    pub(crate) fn from_session(
114        session: crate::session::Session,
115        settings: UiSettings,
116    ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpClientHandle) {
117        let Session { client, response, working_dir, workspace_status, workspace_access } = session;
118        let mut app = Self::new(AppConfig {
119            initialize_response: client.initialize_response,
120            session_response: response,
121            workspace_status,
122            workspace_access,
123            working_dir,
124            settings,
125            browser_opener: default_browser_opener(),
126            clipboard_writer: default_clipboard_writer(),
127        });
128        app.resolve_workspace(app.session.working_dir().to_path_buf());
129        (app, client.event_rx, client.handle)
130    }
131
132    pub fn new(config: AppConfig) -> Self {
133        let (theme, theme_error) = match Theme::load_selection(&config.settings.theme) {
134            Ok(theme) => (theme, None),
135            Err(error) => (Theme::default(), Some(error)),
136        };
137        let ui = UiConfig {
138            content_padding: resolve_content_padding(&config.settings),
139            status_line: resolve_status_line_settings(&config.settings),
140            keybindings: Keybindings::from_settings(&config.settings),
141            theme,
142            theme_generation: Generation::default(),
143            settings: SettingsModel::new(config.settings.clone()),
144        };
145        let capabilities = AetherCapabilities::from_meta(
146            config.initialize_response.capabilities.session.as_ref().and_then(|session| session.meta.as_ref()),
147        );
148        let initial_commands = builtin_commands(&capabilities);
149        let browser_opener = config.browser_opener.clone();
150        let clipboard_writer = config.clipboard_writer.clone();
151        let mut app = Self {
152            session: SessionModel::from_config(config, capabilities),
153            ui,
154            available_commands: initial_commands,
155            route: Route::Conversation,
156            overlay: None,
157            conversation: Conversation::new(),
158            plan_tracker: PlanTracker::default(),
159            progress: ProgressIndicator::default(),
160            spinner_tick: 0,
161            composer: Composer::new(),
162            exit_state: ExitState::Idle,
163            commands: VecDeque::new(),
164            foreground: ForegroundOperation::Idle,
165            browser_opener,
166            clipboard_writer,
167        };
168        if let Some(error) = theme_error {
169            app.notify(&format!("Could not load selected theme: {error}"));
170        }
171        app
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.plan_tracker.sync(self.conversation.plan(), Instant::now());
186        self.refresh_progress();
187        self.take_commands()
188    }
189
190    pub fn take_commands(&mut self) -> Vec<Command> {
191        self.commands.drain(..).collect()
192    }
193
194    #[allow(clippy::too_many_lines)]
195    pub fn on_command_result(&mut self, result: CommandResult) {
196        match result {
197            CommandResult::Prompt(Ok(_)) => self.conversation.accept_prompt(),
198            CommandResult::Prompt(Err(error)) => {
199                self.conversation.fail_prompt(&error);
200                self.foreground.drop_prepared_prompt();
201                self.notify(&format!("Failed to send prompt: {error}"));
202            }
203            CommandResult::Cancel(result) | CommandResult::AuthenticateMcp(result) => {
204                if let Err(error) = result {
205                    self.notify(&error);
206                }
207            }
208            CommandResult::NewSession(result) => match result {
209                Ok(response) => self.on_new_session(response.session_id, response.config_options),
210                Err(error) => {
211                    self.foreground = ForegroundOperation::Idle;
212                    self.notify(&format!("Failed to create new session: {error}"));
213                }
214            },
215            CommandResult::ResumeSession { session_id, result } => {
216                if !matches!(&self.foreground,
217                    ForegroundOperation::ResumingSession { session_id: expected, .. }
218                    | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, .. } if expected == &session_id)
219                {
220                    return;
221                }
222                match result {
223                    Ok(response) => self.on_resumed_session(&session_id, response),
224                    Err(error) => {
225                        self.foreground = ForegroundOperation::Idle;
226                        self.notify(&format!("Failed to resume session: {error}"));
227                    }
228                }
229            }
230            CommandResult::ConfigOptionsUpdated { conversation_id, result: Ok(response) } => {
231                if conversation_id != self.conversation_id() {
232                    return;
233                }
234                self.session.update_config_options(response.config_options);
235                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
236                    overlay.update_config_options(self.session.config_options());
237                }
238            }
239            CommandResult::ConfigOptionsUpdated { conversation_id, result: Err(error) } => {
240                if conversation_id != self.conversation_id() {
241                    return;
242                }
243                tracing::warn!("set_session_config_option failed: {error}");
244                self.notify(&format!("Failed to update setting: {error}"));
245            }
246            CommandResult::AuthenticationCompleted { method_id, result: Ok(_) } => {
247                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
248                    overlay.on_authenticate_complete(&method_id);
249                }
250            }
251            CommandResult::AuthenticationCompleted { method_id, result: Err(error) } => {
252                tracing::warn!("Provider authentication failed for {method_id}: {error}");
253                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
254                    overlay.on_authenticate_failed(&method_id);
255                }
256            }
257            CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
258            CommandResult::GitReview(state) => {
259                if let Route::GitReview(screen) = &mut self.route {
260                    screen.install(&state);
261                }
262            }
263            CommandResult::GitReviewAction(result) => {
264                if let Route::GitReview(screen) = &mut self.route {
265                    screen.on_action_result(result);
266                }
267            }
268            CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
269            CommandResult::ThemesListed(files) => {
270                let entries = build_theme_entries(self.ui.settings.ui(), &files);
271                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
272                    overlay.upsert_local_entries(entries);
273                }
274            }
275            CommandResult::ReviewThemesListed(choices) => match &mut self.route {
276                Route::GitReview(screen) => screen.set_theme_choices(choices),
277                Route::ArtifactReview(screen) => screen.set_theme_choices(choices),
278                Route::Conversation => {}
279            },
280            CommandResult::ThemeApplied(result) => self.finish_theme_change(result),
281            CommandResult::WorkspaceResolved { cwd, status } => {
282                if self.session.working_dir() == cwd {
283                    self.session.set_workspace_status(status);
284                }
285            }
286            CommandResult::SessionsListed(Ok(response)) => self.open_session_picker(response.sessions),
287            CommandResult::SessionsListed(Err(error)) => self.notify(&format!("Failed to list sessions: {error}")),
288            CommandResult::PromptSearchResults { result: Ok(response), .. } => {
289                self.composer.prompt_search_on_results(response);
290            }
291            CommandResult::PromptSearchResults { query, result: Err(error) } => {
292                if let Some(picker) = self.composer.prompt_search_mut() {
293                    picker.on_failed(&query, error);
294                }
295            }
296            CommandResult::SessionPreviewLoaded { result: Ok(preview), .. } => {
297                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
298                    picker.on_preview_loaded(preview);
299                }
300            }
301            CommandResult::SessionPreviewLoaded { session_id, result: Err(error) } => {
302                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
303                    picker.on_preview_failed(&session_id, error);
304                }
305            }
306            CommandResult::WorkspacesListed(Ok(response)) => {
307                self.open_overlay(Overlay::Workspaces(WorkspacePicker::new(response.workspaces)));
308                self.foreground = ForegroundOperation::PickingWorkspace;
309            }
310            CommandResult::WorkspacesListed(Err(error)) => {
311                self.abandon_workspace_move(&format!("Failed to list workspaces: {error}"));
312            }
313            CommandResult::WorkspaceMoved(Ok(response)) => self.on_workspace_moved(response.new_cwd),
314            CommandResult::WorkspaceMoved(Err(error)) => {
315                self.abandon_workspace_move(&format!("Workspace move failed: {error}"));
316            }
317            CommandResult::BackgroundFailed(error) | CommandResult::TerminalFailed(error) => self.notify(&error),
318        }
319    }
320
321    fn start_prompt(
322        &mut self,
323        text: String,
324        content: Option<Vec<acp::ContentBlock>>,
325        echo: Option<Vec<acp::ContentBlock>>,
326    ) {
327        if let Err(error) = self.conversation.start_prompt(echo) {
328            self.notify(&error.to_string());
329            return;
330        }
331        self.queue(Command::Agent(AgentCommand::Prompt {
332            session_id: self.session.session_id().clone(),
333            text,
334            content,
335        }));
336    }
337
338    fn queue(&mut self, command: Command) {
339        self.commands.push_back(command);
340    }
341
342    /// Queues a config change for the agent and session the app is attached to.
343    fn set_config_option(&mut self, config_id: &str, value: &str) {
344        self.queue(Command::Agent(AgentCommand::SetConfigOption {
345            conversation_id: self.conversation_id(),
346            session_id: self.session.session_id().clone(),
347            config_id: config_id.to_string(),
348            value: value.into(),
349        }));
350    }
351
352    pub fn on_tick(&mut self, now: Instant) {
353        if let ExitState::Confirming(armed_at) = self.exit_state
354            && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
355        {
356            self.exit_state = ExitState::Idle;
357        }
358        if self.progress.is_active() {
359            self.spinner_tick = self.spinner_tick.wrapping_add(1);
360        }
361        self.progress.on_tick(now);
362        self.plan_tracker.on_tick(now);
363    }
364
365    pub fn wants_tick(&self) -> bool {
366        self.waiting_for_response()
367            || matches!(
368                self.foreground,
369                ForegroundOperation::ListingWorkspaces
370                    | ForegroundOperation::PickingWorkspace
371                    | ForegroundOperation::MovingWorkspace
372                    | ForegroundOperation::LoadingWorkspaceSession { .. }
373            )
374            || self.conversation.any_running()
375            || self.conversation.is_compacting()
376            || self.progress.is_active()
377            || self.exit_state.is_confirming()
378            || self.plan_tracker.has_completed_in_grace_period()
379    }
380
381    pub fn has_navigation(&self) -> bool {
382        self.overlay.is_some() || self.route.is_fullscreen()
383    }
384
385    pub fn has_session_picker(&self) -> bool {
386        matches!(self.overlay, Some(Overlay::Sessions(_)))
387    }
388
389    pub fn has_modal(&self) -> bool {
390        self.overlay.is_some()
391    }
392
393    pub fn full_screen_active(&self) -> bool {
394        self.route.is_fullscreen()
395    }
396
397    pub fn foreground_operation(&self) -> &ForegroundOperation {
398        &self.foreground
399    }
400
401    pub fn exit_requested(&self) -> bool {
402        self.exit_result().is_some()
403    }
404
405    pub fn exit_result(&self) -> Option<Result<(), crate::error::AppError>> {
406        match self.exit_state {
407            ExitState::Exiting => Some(Ok(())),
408            ExitState::ConnectionLost => Some(Err(crate::error::AppError::ConnectionLost)),
409            ExitState::Idle | ExitState::Confirming(_) => None,
410        }
411    }
412
413    pub fn session_id(&self) -> &acp::SessionId {
414        self.session.session_id()
415    }
416
417    pub fn conversation_items(&self) -> &[ConversationItem] {
418        self.conversation.items()
419    }
420
421    pub fn conversation_id(&self) -> ConversationId {
422        self.conversation.id()
423    }
424
425    pub fn composer(&self) -> &Composer {
426        &self.composer
427    }
428
429    pub(crate) fn composer_mut(&mut self) -> &mut Composer {
430        &mut self.composer
431    }
432
433    pub fn config_options(&self) -> &[LocalConfigOption] {
434        self.session.config_options()
435    }
436
437    pub fn auth_methods(&self) -> &[acp::AuthMethod] {
438        self.session.auth_methods()
439    }
440
441    pub(crate) fn content_padding(&self) -> usize {
442        self.ui.content_padding
443    }
444
445    /// Everything the status line reads, gathered for one frame.
446    pub fn status_line_model(&self) -> StatusLineModel<'_> {
447        StatusLineModel {
448            settings: &self.ui.status_line,
449            config_options: self.session.config_options(),
450            workspace: self.session.workspace_status(),
451            agent_name: self.session.agent_name(),
452            content_padding: self.ui.content_padding,
453            context_usage: self.conversation.context_usage().map(ContextUsageDisplay::from),
454            unhealthy_servers: self.session.unhealthy_server_count(),
455            waiting_for_response: self.waiting_for_response(),
456            exit_confirmation: self.exit_state.is_confirming(),
457        }
458    }
459
460    pub fn ui_settings(&self) -> &UiSettings {
461        self.ui.settings.ui()
462    }
463
464    /// The active theme; the renderer resyncs when `theme_generation` moves.
465    pub fn theme(&self) -> &Theme {
466        &self.ui.theme
467    }
468
469    pub(crate) fn theme_generation(&self) -> Generation {
470        self.ui.theme_generation
471    }
472
473    /// A prompt is outstanding, so the agent owes us a reply.
474    pub fn waiting_for_response(&self) -> bool {
475        self.conversation.turn().waiting_for_response()
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}