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    swallow_next_click: bool,
78    /// What the event loop still owes the outside world.
79    commands: VecDeque<Command>,
80    foreground: ForegroundOperation,
81    browser_opener: BrowserOpener,
82    clipboard_writer: ClipboardWriter,
83}
84
85/// How the UI is configured, as opposed to what it is currently showing.
86struct UiConfig {
87    settings: SettingsModel,
88    keybindings: Keybindings,
89    content_padding: usize,
90    status_line: ResolvedStatusLineSettings,
91    theme: Theme,
92    theme_generation: Generation,
93}
94
95pub struct AppConfig {
96    pub initialize_response: acp::InitializeResponse,
97    pub session_response: acp::NewSessionResponse,
98    pub workspace_status: WorkspaceStatus,
99    pub workspace_access: WorkspaceAccess,
100    pub working_dir: PathBuf,
101    pub settings: UiSettings,
102    /// Host services the UI reaches for; injected so tests observe URL opens
103    /// and clipboard writes without spawning anything.
104    pub browser_opener: BrowserOpener,
105    pub clipboard_writer: ClipboardWriter,
106}
107
108impl App {
109    /// Build the UI from a freshly connected ACP session.
110    ///
111    /// Returns the pieces only the process outside the UI needs: the event
112    /// channel feeding the event loop and the handle commands are sent through.
113    /// Crate-internal entry-point wiring, not part of the public or test API.
114    pub(crate) fn from_session(
115        session: crate::session::Session,
116        settings: UiSettings,
117    ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpClientHandle) {
118        let Session { client, response, working_dir, workspace_status, workspace_access } = session;
119        let mut app = Self::new(AppConfig {
120            initialize_response: client.initialize_response,
121            session_response: response,
122            workspace_status,
123            workspace_access,
124            working_dir,
125            settings,
126            browser_opener: default_browser_opener(),
127            clipboard_writer: default_clipboard_writer(),
128        });
129        app.resolve_workspace(app.session.working_dir().to_path_buf());
130        (app, client.event_rx, client.handle)
131    }
132
133    pub fn new(config: AppConfig) -> Self {
134        let (theme, theme_error) = match Theme::load_selection(&config.settings.theme) {
135            Ok(theme) => (theme, None),
136            Err(error) => (Theme::default(), Some(error)),
137        };
138        let ui = UiConfig {
139            content_padding: resolve_content_padding(&config.settings),
140            status_line: resolve_status_line_settings(&config.settings),
141            keybindings: Keybindings::from_settings(&config.settings),
142            theme,
143            theme_generation: Generation::default(),
144            settings: SettingsModel::new(config.settings.clone()),
145        };
146        let capabilities = AetherCapabilities::from_meta(
147            config.initialize_response.capabilities.session.as_ref().and_then(|session| session.meta.as_ref()),
148        );
149        let initial_commands = builtin_commands(&capabilities);
150        let browser_opener = config.browser_opener.clone();
151        let clipboard_writer = config.clipboard_writer.clone();
152        let mut app = Self {
153            session: SessionModel::from_config(config, capabilities),
154            ui,
155            available_commands: initial_commands,
156            route: Route::Conversation,
157            overlay: None,
158            conversation: Conversation::new(),
159            plan_tracker: PlanTracker::default(),
160            progress: ProgressIndicator::default(),
161            spinner_tick: 0,
162            composer: Composer::new(),
163            exit_state: ExitState::Idle,
164            swallow_next_click: false,
165            commands: VecDeque::new(),
166            foreground: ForegroundOperation::Idle,
167            browser_opener,
168            clipboard_writer,
169        };
170        if let Some(error) = theme_error {
171            app.notify(&format!("Could not load selected theme: {error}"));
172        }
173        app
174    }
175
176    /// Reduce one external input and return its commands.
177    ///
178    /// This is the synchronous model boundary used by the runtime dispatcher.
179    pub fn update(&mut self, message: Message) -> Vec<Command> {
180        match message {
181            Message::Terminal(event) => self.on_terminal_event(event),
182            Message::Agent(event) => self.on_acp_event(*event),
183            Message::CommandFinished(result) => self.on_command_result(*result),
184            Message::Tick(now) => self.on_tick(now),
185        }
186
187        self.plan_tracker.sync(self.conversation.plan(), Instant::now());
188        self.refresh_progress();
189        self.take_commands()
190    }
191
192    pub fn take_commands(&mut self) -> Vec<Command> {
193        self.commands.drain(..).collect()
194    }
195
196    #[allow(clippy::too_many_lines)]
197    pub fn on_command_result(&mut self, result: CommandResult) {
198        match result {
199            CommandResult::Prompt(Ok(_)) => self.conversation.accept_prompt(),
200            CommandResult::Prompt(Err(error)) => {
201                self.conversation.fail_prompt(&error);
202                self.foreground.drop_prepared_prompt();
203                self.notify(&format!("Failed to send prompt: {error}"));
204            }
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.on_authenticate_complete(&method_id);
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.on_authenticate_failed(&method_id);
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(
324        &mut self,
325        text: String,
326        content: Option<Vec<acp::ContentBlock>>,
327        echo: Option<Vec<acp::ContentBlock>>,
328    ) {
329        if let Err(error) = self.conversation.start_prompt(echo) {
330            self.notify(&error.to_string());
331            return;
332        }
333        self.queue(Command::Agent(AgentCommand::Prompt {
334            session_id: self.session.session_id().clone(),
335            text,
336            content,
337        }));
338    }
339
340    fn queue(&mut self, command: Command) {
341        self.commands.push_back(command);
342    }
343
344    /// Queues a config change for the agent and session the app is attached to.
345    fn set_config_option(&mut self, config_id: &str, value: &str) {
346        self.queue(Command::Agent(AgentCommand::SetConfigOption {
347            conversation_id: self.conversation_id(),
348            session_id: self.session.session_id().clone(),
349            config_id: config_id.to_string(),
350            value: value.into(),
351        }));
352    }
353
354    pub fn on_tick(&mut self, now: Instant) {
355        if let ExitState::Confirming(armed_at) = self.exit_state
356            && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
357        {
358            self.exit_state = ExitState::Idle;
359        }
360        if self.progress.is_active() {
361            self.spinner_tick = self.spinner_tick.wrapping_add(1);
362        }
363        self.progress.on_tick(now);
364        self.plan_tracker.on_tick(now);
365    }
366
367    pub fn wants_tick(&self) -> bool {
368        self.waiting_for_response()
369            || matches!(
370                self.foreground,
371                ForegroundOperation::ListingWorkspaces
372                    | ForegroundOperation::PickingWorkspace
373                    | ForegroundOperation::MovingWorkspace
374                    | ForegroundOperation::LoadingWorkspaceSession { .. }
375            )
376            || self.conversation.any_running()
377            || self.conversation.is_compacting()
378            || self.progress.is_active()
379            || self.exit_state.is_confirming()
380            || self.plan_tracker.has_completed_in_grace_period()
381    }
382
383    pub fn has_navigation(&self) -> bool {
384        self.overlay.is_some() || self.route.is_fullscreen()
385    }
386
387    pub fn has_session_picker(&self) -> bool {
388        matches!(self.overlay, Some(Overlay::Sessions(_)))
389    }
390
391    pub fn has_modal(&self) -> bool {
392        self.overlay.is_some()
393    }
394
395    pub fn full_screen_active(&self) -> bool {
396        self.route.is_fullscreen()
397    }
398
399    pub fn foreground_operation(&self) -> &ForegroundOperation {
400        &self.foreground
401    }
402
403    pub fn exit_requested(&self) -> bool {
404        self.exit_result().is_some()
405    }
406
407    pub fn exit_result(&self) -> Option<Result<(), crate::error::AppError>> {
408        match self.exit_state {
409            ExitState::Exiting => Some(Ok(())),
410            ExitState::ConnectionLost => Some(Err(crate::error::AppError::ConnectionLost)),
411            ExitState::Idle | ExitState::Confirming(_) => None,
412        }
413    }
414
415    pub fn session_id(&self) -> &acp::SessionId {
416        self.session.session_id()
417    }
418
419    pub fn conversation_items(&self) -> &[ConversationItem] {
420        self.conversation.items()
421    }
422
423    pub fn conversation_id(&self) -> ConversationId {
424        self.conversation.id()
425    }
426
427    pub fn composer(&self) -> &Composer {
428        &self.composer
429    }
430
431    pub(crate) fn composer_mut(&mut self) -> &mut Composer {
432        &mut self.composer
433    }
434
435    pub fn config_options(&self) -> &[LocalConfigOption] {
436        self.session.config_options()
437    }
438
439    pub fn auth_methods(&self) -> &[acp::AuthMethod] {
440        self.session.auth_methods()
441    }
442
443    pub(crate) fn content_padding(&self) -> usize {
444        self.ui.content_padding
445    }
446
447    /// Everything the status line reads, gathered for one frame.
448    pub fn status_line_model(&self) -> StatusLineModel<'_> {
449        StatusLineModel {
450            settings: &self.ui.status_line,
451            config_options: self.session.config_options(),
452            workspace: self.session.workspace_status(),
453            agent_name: self.session.agent_name(),
454            content_padding: self.ui.content_padding,
455            context_usage: self.conversation.context_usage().map(ContextUsageDisplay::from),
456            unhealthy_servers: self.session.unhealthy_server_count(),
457            waiting_for_response: self.waiting_for_response(),
458            exit_confirmation: self.exit_state.is_confirming(),
459        }
460    }
461
462    pub fn ui_settings(&self) -> &UiSettings {
463        self.ui.settings.ui()
464    }
465
466    /// The active theme; the renderer resyncs when `theme_generation` moves.
467    pub fn theme(&self) -> &Theme {
468        &self.ui.theme
469    }
470
471    pub(crate) fn theme_generation(&self) -> Generation {
472        self.ui.theme_generation
473    }
474
475    /// A prompt is outstanding, so the agent owes us a reply.
476    pub fn waiting_for_response(&self) -> bool {
477        self.conversation.turn().waiting_for_response()
478    }
479
480    /// Either the prompt or one of its tool calls is still running.
481    pub fn is_agent_busy(&self) -> bool {
482        self.waiting_for_response() || self.conversation.any_running()
483    }
484
485    pub fn progress_indicator(&self) -> &ProgressIndicator {
486        &self.progress
487    }
488
489    /// Test seam: the status line reads this through
490    /// [`App::status_line_model`] rather than calling it.
491    pub fn exit_confirmation_active(&self) -> bool {
492        self.exit_state.is_confirming()
493    }
494
495    pub(crate) fn spinner_tick(&self) -> usize {
496        self.spinner_tick
497    }
498
499    pub fn plan_entries(&self) -> Vec<PlanEntry> {
500        self.plan_tracker.current_entries()
501    }
502
503    /// Reaches past the renderer for the integration tests, which assert on the
504    /// state a frame is drawn from rather than on the frame.
505    pub fn has_plan(&self) -> bool {
506        self.plan_tracker.has_entries()
507    }
508
509    /// Drops all conversation state atomically before starting a new session.
510    fn reset_conversation(&mut self) {
511        self.conversation.clear();
512        self.foreground.drop_prepared_prompt();
513    }
514
515    fn refresh_progress(&mut self) {
516        let override_phase = match self.foreground {
517            ForegroundOperation::MovingWorkspace => Some(ProgressPhase::MovingWorkspace),
518            ForegroundOperation::LoadingWorkspaceSession { .. } => Some(ProgressPhase::LoadingSession),
519            _ if self.conversation.is_compacting() => Some(ProgressPhase::Compacting),
520            _ => None,
521        };
522        let interruptible = self.is_agent_busy();
523        self.progress.refresh(&self.conversation, override_phase, interruptible);
524    }
525
526    fn return_to_conversation(&mut self) {
527        self.open_route(Route::Conversation);
528    }
529}