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