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
63pub 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 commands: VecDeque<Command>,
79 foreground: ForegroundOperation,
80 browser_opener: BrowserOpener,
81 clipboard_writer: ClipboardWriter,
82}
83
84struct 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 pub browser_opener: BrowserOpener,
104 pub clipboard_writer: ClipboardWriter,
105}
106
107impl App {
108 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 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 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 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 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 pub fn waiting_for_response(&self) -> bool {
475 self.conversation.turn().waiting_for_response()
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}