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
66pub 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 commands: VecDeque<Command>,
83 foreground: ForegroundOperation,
84 queued_prompts: Vec<QueuedPrompt>,
85 browser_opener: BrowserOpener,
86 clipboard_writer: ClipboardWriter,
87}
88
89struct 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 pub browser_opener: BrowserOpener,
109 pub clipboard_writer: ClipboardWriter,
110}
111
112impl App {
113 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 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 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 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 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 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 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 pub fn has_plan(&self) -> bool {
504 self.plan_tracker.has_entries()
505 }
506
507 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}