1use crate::Session;
2use crate::app::keybindings::Keybindings;
3use crate::app::message::Message;
4use crate::command::{AgentCommand, Command, CommandResult};
5use crate::conversation::items::{Conversation, ConversationItem};
6use crate::conversation::progress_indicator::{ProgressIndicator, ProgressPhase};
7use crate::conversation::status_line::StatusLineModel;
8use crate::conversation::tool_calls::ToolStatus;
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;
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::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, PromptPhase};
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 composer: Composer,
73 exit_state: ExitState,
74 commands: VecDeque<Command>,
76 foreground: ForegroundOperation,
77 browser_opener: BrowserOpener,
78 clipboard_writer: ClipboardWriter,
79}
80
81struct UiConfig {
83 settings: SettingsModel,
84 keybindings: Keybindings,
85 content_padding: usize,
86 status_line: ResolvedStatusLineSettings,
87 theme: Theme,
88 theme_generation: Generation,
89}
90
91pub struct AppConfig {
92 pub initialize_response: acp::InitializeResponse,
93 pub session_response: acp::NewSessionResponse,
94 pub workspace_status: WorkspaceStatus,
95 pub workspace_access: WorkspaceAccess,
96 pub working_dir: PathBuf,
97 pub settings: UiSettings,
98 pub browser_opener: BrowserOpener,
101 pub clipboard_writer: ClipboardWriter,
102}
103
104impl App {
105 pub(crate) fn from_session(
111 session: crate::session::Session,
112 settings: UiSettings,
113 ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpClientHandle) {
114 let Session { client, response, working_dir, workspace_status, workspace_access } = session;
115 let mut app = Self::new(AppConfig {
116 initialize_response: client.initialize_response,
117 session_response: response,
118 workspace_status,
119 workspace_access,
120 working_dir,
121 settings,
122 browser_opener: default_browser_opener(),
123 clipboard_writer: default_clipboard_writer(),
124 });
125 app.resolve_workspace(app.session.working_dir().to_path_buf());
126 (app, client.event_rx, client.handle)
127 }
128
129 pub fn new(config: AppConfig) -> Self {
130 let (theme, theme_error) = match Theme::load_selection(&config.settings.theme) {
131 Ok(theme) => (theme, None),
132 Err(error) => (Theme::default(), Some(error)),
133 };
134 let ui = UiConfig {
135 content_padding: resolve_content_padding(&config.settings),
136 status_line: resolve_status_line_settings(&config.settings),
137 keybindings: Keybindings::from_settings(&config.settings),
138 theme,
139 theme_generation: Generation::default(),
140 settings: SettingsModel::new(config.settings.clone()),
141 };
142 let capabilities = AetherCapabilities::from_meta(
143 config.initialize_response.capabilities.session.as_ref().and_then(|session| session.meta.as_ref()),
144 );
145 let initial_commands = builtin_commands(&capabilities);
146 let browser_opener = config.browser_opener.clone();
147 let clipboard_writer = config.clipboard_writer.clone();
148 let mut app = Self {
149 session: SessionModel::from_config(config, capabilities),
150 ui,
151 available_commands: initial_commands,
152 route: Route::Conversation,
153 overlay: None,
154 conversation: Conversation::default(),
155 composer: Composer::new(),
156 exit_state: ExitState::Idle,
157 commands: VecDeque::new(),
158 foreground: ForegroundOperation::Idle,
159 browser_opener,
160 clipboard_writer,
161 };
162 if let Some(error) = theme_error {
163 app.notify(&format!("Could not load selected theme: {error}"));
164 }
165 app
166 }
167
168 pub fn update(&mut self, message: Message) -> Vec<Command> {
172 match message {
173 Message::Terminal(event) => self.on_terminal_event(event),
174 Message::Agent(event) => self.on_acp_event(*event),
175 Message::CommandFinished(result) => self.on_command_result(*result),
176 Message::Tick(now) => self.on_tick(now),
177 }
178
179 self.refresh_progress();
180 self.take_commands()
181 }
182
183 pub fn take_commands(&mut self) -> Vec<Command> {
184 self.commands.drain(..).collect()
185 }
186
187 #[allow(clippy::too_many_lines)]
188 pub fn on_command_result(&mut self, result: CommandResult) {
189 match result {
190 CommandResult::Prompt(Ok(_)) => self.foreground.accept_prompt(),
191 CommandResult::Prompt(Err(error)) => {
192 if self.waiting_for_response() {
193 self.finish_prompt(&ToolStatus::Error(format!("failed: {error}")));
194 }
195 self.foreground.reject_prompt();
196 self.notify(&format!("Failed to send prompt: {error}"));
197 }
198 CommandResult::Cancel(result) | CommandResult::AuthenticateMcp(result) => {
199 if let Err(error) = result {
200 self.notify(&error);
201 }
202 }
203 CommandResult::NewSession(result) => match result {
204 Ok(response) => self.on_new_session(response.session_id, response.config_options),
205 Err(error) => {
206 self.foreground = ForegroundOperation::Idle;
207 self.notify(&format!("Failed to create new session: {error}"));
208 }
209 },
210 CommandResult::ResumeSession { session_id, result } => {
211 if !matches!(&self.foreground,
212 ForegroundOperation::ResumingSession { session_id: expected, .. }
213 | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, .. } if expected == &session_id)
214 {
215 return;
216 }
217 match result {
218 Ok(response) => self.on_resumed_session(&session_id, response),
219 Err(error) => {
220 self.foreground = ForegroundOperation::Idle;
221 self.notify(&format!("Failed to resume session: {error}"));
222 }
223 }
224 }
225 CommandResult::ConfigOptionsUpdated { conversation_id, result: Ok(response) } => {
226 if conversation_id != self.conversation_id() {
227 return;
228 }
229 self.session.update_config_options(response.config_options);
230 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
231 overlay.update_config_options(self.session.config_options());
232 }
233 }
234 CommandResult::ConfigOptionsUpdated { conversation_id, result: Err(error) } => {
235 if conversation_id != self.conversation_id() {
236 return;
237 }
238 tracing::warn!("set_session_config_option failed: {error}");
239 self.notify(&format!("Failed to update setting: {error}"));
240 }
241 CommandResult::AuthenticationCompleted { method_id, result: Ok(_) } => {
242 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
243 overlay.on_authenticate_complete(&method_id);
244 }
245 }
246 CommandResult::AuthenticationCompleted { method_id, result: Err(error) } => {
247 tracing::warn!("Provider authentication failed for {method_id}: {error}");
248 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
249 overlay.on_authenticate_failed(&method_id);
250 }
251 }
252 CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
253 CommandResult::GitDiff(event) => {
254 if let Route::GitReview(screen) = &mut self.route {
255 screen.on_event(event);
256 }
257 }
258 CommandResult::GitWatchStarted { .. } => {}
259 CommandResult::GitWatch(event) => {
260 if let Route::GitReview(screen) = &mut self.route {
261 screen.on_watch_event(event);
262 }
263 }
264 CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
265 CommandResult::ThemesListed(files) => {
266 let entries = build_theme_entries(self.ui.settings.ui(), &files);
267 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
268 overlay.upsert_local_entries(entries);
269 }
270 }
271 CommandResult::ReviewThemesListed(choices) => match &mut self.route {
272 Route::GitReview(screen) => screen.set_theme_choices(choices),
273 Route::ArtifactReview(screen) => screen.set_theme_choices(choices),
274 Route::Conversation => {}
275 },
276 CommandResult::ThemeApplied(result) => self.finish_theme_change(result),
277 CommandResult::WorkspaceResolved { cwd, status } => {
278 if self.session.working_dir() == cwd {
279 self.session.set_workspace_status(status);
280 }
281 }
282 CommandResult::SessionsListed(Ok(response)) => self.open_session_picker(response.sessions),
283 CommandResult::SessionsListed(Err(error)) => self.notify(&format!("Failed to list sessions: {error}")),
284 CommandResult::PromptSearchResults { result: Ok(response), .. } => {
285 self.composer.prompt_search_on_results(response);
286 }
287 CommandResult::PromptSearchResults { query, result: Err(error) } => {
288 if let Some(picker) = self.composer.prompt_search_mut() {
289 picker.on_failed(&query, error);
290 }
291 }
292 CommandResult::SessionPreviewLoaded { result: Ok(preview), .. } => {
293 if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
294 picker.on_preview_loaded(preview);
295 }
296 }
297 CommandResult::SessionPreviewLoaded { session_id, result: Err(error) } => {
298 if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
299 picker.on_preview_failed(&session_id, error);
300 }
301 }
302 CommandResult::WorkspacesListed(Ok(response)) => {
303 self.open_overlay(Overlay::Workspaces(WorkspacePicker::new(
304 response.workspaces,
305 self.session.workspace_access(),
306 )));
307 self.foreground = ForegroundOperation::PickingWorkspace;
308 }
309 CommandResult::WorkspacesListed(Err(error)) => {
310 self.abandon_workspace_move(&format!("Failed to list workspaces: {error}"));
311 }
312 CommandResult::WorkspaceMoved(Ok(response)) => self.on_workspace_moved(response.new_cwd),
313 CommandResult::WorkspaceMoved(Err(error)) => {
314 self.abandon_workspace_move(&format!("Workspace move failed: {error}"));
315 }
316 CommandResult::BackgroundFailed(error) | CommandResult::TerminalFailed(error) => self.notify(&error),
317 }
318 }
319
320 fn start_prompt(&mut self, text: String, content: Option<Vec<acp::ContentBlock>>) {
321 self.foreground = ForegroundOperation::Prompt(PromptPhase::Submitting);
322 self.conversation.progress_indicator_mut().prompt_started();
323 self.queue(Command::Agent(AgentCommand::Prompt {
324 session_id: self.session.session_id().clone(),
325 text,
326 content,
327 }));
328 }
329
330 fn queue(&mut self, command: Command) {
331 self.commands.push_back(command);
332 }
333
334 fn set_config_option(&mut self, config_id: &str, value: &str) {
336 self.queue(Command::Agent(AgentCommand::SetConfigOption {
337 conversation_id: self.conversation_id(),
338 session_id: self.session.session_id().clone(),
339 config_id: config_id.to_string(),
340 value: value.into(),
341 }));
342 }
343
344 pub fn on_tick(&mut self, now: Instant) {
345 if let ExitState::Confirming(armed_at) = self.exit_state
346 && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
347 {
348 self.exit_state = ExitState::Idle;
349 }
350 if self.conversation.progress_indicator().is_active() {
351 self.conversation.turn_mut().advance_spinner();
352 }
353 self.conversation.progress_indicator_mut().on_tick(now);
354 self.conversation.plan_tracker_mut().on_tick(now);
355 }
356
357 pub fn wants_tick(&self) -> bool {
358 self.waiting_for_response()
359 || matches!(
360 self.foreground,
361 ForegroundOperation::ListingWorkspaces
362 | ForegroundOperation::PickingWorkspace
363 | ForegroundOperation::MovingWorkspace
364 | ForegroundOperation::LoadingWorkspaceSession { .. }
365 )
366 || self.conversation.any_running()
367 || self.conversation.turn().is_compaction_active()
368 || self.conversation.progress_indicator().is_active()
369 || self.exit_state.is_confirming()
370 || self.conversation.plan_tracker().has_completed_in_grace_period()
371 }
372
373 pub fn has_navigation(&self) -> bool {
374 self.overlay.is_some() || self.route.is_fullscreen()
375 }
376
377 pub fn has_session_picker(&self) -> bool {
378 matches!(self.overlay, Some(Overlay::Sessions(_)))
379 }
380
381 pub fn has_modal(&self) -> bool {
382 self.overlay.is_some()
383 }
384
385 pub fn full_screen_active(&self) -> bool {
386 self.route.is_fullscreen()
387 }
388
389 pub fn foreground_operation(&self) -> &ForegroundOperation {
390 &self.foreground
391 }
392
393 pub fn exit_requested(&self) -> bool {
394 self.exit_result().is_some()
395 }
396
397 pub fn exit_result(&self) -> Option<Result<(), crate::error::AppError>> {
398 match self.exit_state {
399 ExitState::Exiting => Some(Ok(())),
400 ExitState::ConnectionLost => Some(Err(crate::error::AppError::ConnectionLost)),
401 ExitState::Idle | ExitState::Confirming(_) => None,
402 }
403 }
404
405 pub fn session_id(&self) -> &acp::SessionId {
406 self.session.session_id()
407 }
408
409 pub fn conversation_items(&self) -> &[ConversationItem] {
410 self.conversation.items()
411 }
412
413 pub fn conversation_id(&self) -> crate::conversation::ConversationId {
414 self.conversation.id()
415 }
416
417 pub fn composer(&self) -> &Composer {
418 &self.composer
419 }
420
421 pub(crate) fn composer_mut(&mut self) -> &mut Composer {
422 &mut self.composer
423 }
424
425 pub fn config_options(&self) -> &[LocalConfigOption] {
426 self.session.config_options()
427 }
428
429 pub fn auth_methods(&self) -> &[acp::AuthMethod] {
430 self.session.auth_methods()
431 }
432
433 pub(crate) fn content_padding(&self) -> usize {
434 self.ui.content_padding
435 }
436
437 pub fn status_line_model(&self) -> StatusLineModel<'_> {
439 StatusLineModel {
440 settings: &self.ui.status_line,
441 config_options: self.session.config_options(),
442 workspace: self.session.workspace_status(),
443 agent_name: self.session.agent_name(),
444 content_padding: self.ui.content_padding,
445 context_usage: self.conversation.turn().context_usage(),
446 unhealthy_servers: self.session.unhealthy_server_count(),
447 waiting_for_response: self.waiting_for_response(),
448 exit_confirmation: self.exit_state.is_confirming(),
449 }
450 }
451
452 pub fn ui_settings(&self) -> &UiSettings {
453 self.ui.settings.ui()
454 }
455
456 pub fn theme(&self) -> &Theme {
458 &self.ui.theme
459 }
460
461 pub(crate) fn theme_generation(&self) -> Generation {
462 self.ui.theme_generation
463 }
464
465 pub fn waiting_for_response(&self) -> bool {
467 self.foreground.prompt_in_flight()
468 }
469
470 pub fn is_agent_busy(&self) -> bool {
472 self.waiting_for_response() || self.conversation.any_running()
473 }
474
475 pub fn progress_indicator(&self) -> &ProgressIndicator {
476 self.conversation.progress_indicator()
477 }
478
479 pub fn exit_confirmation_active(&self) -> bool {
482 self.exit_state.is_confirming()
483 }
484
485 pub(crate) fn spinner_tick(&self) -> usize {
486 self.conversation.turn().spinner_tick()
487 }
488
489 pub fn plan_entries(&self) -> Vec<PlanEntry> {
490 self.conversation.plan_tracker().current_entries()
491 }
492
493 pub fn has_plan(&self) -> bool {
496 self.conversation.plan_tracker().has_entries()
497 }
498
499 fn reset_conversation(&mut self) {
501 self.conversation.reset_feature_state();
504 self.foreground.clear_conversation();
505 self.conversation.clear();
506 }
507
508 fn refresh_progress(&mut self) {
509 let override_phase = match self.foreground {
510 ForegroundOperation::MovingWorkspace => Some(ProgressPhase::MovingWorkspace),
511 ForegroundOperation::LoadingWorkspaceSession { .. } => Some(ProgressPhase::LoadingSession),
512 _ if self.conversation.turn().is_compaction_active() => Some(ProgressPhase::Compacting),
513 _ => None,
514 };
515 let interruptible = self.is_agent_busy();
516 self.conversation.progress_indicator_mut().refresh(override_phase, interruptible);
517 }
518
519 fn return_to_conversation(&mut self) {
520 self.open_route(Route::Conversation);
521 }
522}