1use crate::command::{AgentCommand, Command, CommandResult, FailedCommand};
2use crate::app::keybindings::Keybindings;
3use crate::app::message::Message;
4use crate::conversation::items::{Conversation, ConversationItem};
5use crate::conversation::progress_indicator::{ProgressIndicator, ProgressPhase};
6use crate::conversation::status_line::StatusLineModel;
7use crate::conversation::tool_calls::ToolStatus;
8use crate::session::platform::{
9 BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer,
10};
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::input::RootOutput;
19use crate::surfaces::workspace_picker::WorkspacePicker;
20use crate::surfaces::picker::CommandEntry;
21use crate::view::generation::Generation;
22use crate::theme::Theme;
23use acp_utils::client::AcpEvent;
24use acp_utils::notifications::AetherCapabilities;
25use agent_client_protocol::schema::v1::{self as acp, SessionId};
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 crate::session::session_model::WorkspaceMoveState;
35pub use navigation::{Overlay, Route};
36
37mod acp_reducer;
38mod config;
39mod input;
40mod keybindings;
41mod session;
42mod submission;
43use config::build_theme_entries;
44use input::CTRL_C_CONFIRM_WINDOW;
45use session::builtin_commands;
46use submission::SubmissionState;
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub enum ExitState {
50 #[default]
51 Idle,
52 Confirming(Instant),
53 Exiting,
54}
55
56impl ExitState {
57 fn is_confirming(&self) -> bool {
58 matches!(self, ExitState::Confirming(_))
59 }
60}
61
62pub struct App {
65 session: SessionModel,
66 ui: UiConfig,
67 available_commands: Vec<CommandEntry>,
68 route: Route,
69 overlay: Option<Overlay>,
70 conversation: Conversation,
71 composer: Composer,
72 exit_state: ExitState,
73 commands: VecDeque<Command>,
75 submission: SubmissionState,
76 browser_opener: BrowserOpener,
77 clipboard_writer: ClipboardWriter,
78}
79
80struct UiConfig {
82 settings: SettingsModel,
83 keybindings: Keybindings,
84 content_padding: usize,
85 status_line: ResolvedStatusLineSettings,
86 theme: Theme,
87 theme_generation: Generation,
88}
89
90pub struct AppConfig {
91 pub session_id: SessionId,
92 pub agent_name: String,
93 pub workspace_status: WorkspaceStatus,
94 pub prompt_capabilities: acp::PromptCapabilities,
95 pub session_capabilities: acp::SessionCapabilities,
96 pub config_options: Vec<acp::SessionConfigOption>,
97 pub auth_methods: Vec<acp::AuthMethod>,
98 pub working_dir: PathBuf,
99 pub settings: UiSettings,
100 pub browser_opener: BrowserOpener,
103 pub clipboard_writer: ClipboardWriter,
104}
105
106impl App {
107 pub(crate) fn from_session(
113 session: crate::session::Session,
114 settings: UiSettings,
115 ) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpClientHandle) {
116 let crate::session::Session {
117 session_id,
118 agent_name,
119 prompt_capabilities,
120 session_capabilities,
121 config_options,
122 auth_methods,
123 event_rx,
124 client_handle,
125 working_dir,
126 workspace_status,
127 } = session;
128 let mut app = Self::new(AppConfig {
129 session_id,
130 agent_name,
131 workspace_status,
132 prompt_capabilities,
133 session_capabilities,
134 config_options,
135 auth_methods,
136 working_dir,
137 settings,
138 browser_opener: default_browser_opener(),
139 clipboard_writer: default_clipboard_writer(),
140 });
141 app.queue(Command::ResolveWorkspace { cwd: app.session.working_dir().to_path_buf() });
142 (app, event_rx, client_handle)
143 }
144
145 pub fn new(config: AppConfig) -> Self {
146 let ui = UiConfig {
147 content_padding: resolve_content_padding(&config.settings),
148 status_line: resolve_status_line_settings(&config.settings),
149 keybindings: Keybindings::from_settings(&config.settings),
150 theme: Theme::load(&config.settings),
151 theme_generation: Generation::default(),
152 settings: SettingsModel::new(config.settings.clone()),
153 };
154 let capabilities = AetherCapabilities::from_meta(config.session_capabilities.meta.as_ref());
155 let initial_commands = builtin_commands(&capabilities);
156 let browser_opener = config.browser_opener.clone();
157 let clipboard_writer = config.clipboard_writer.clone();
158 Self {
159 session: SessionModel::from_config(config, capabilities),
160 ui,
161 available_commands: initial_commands,
162 route: Route::Conversation,
163 overlay: None,
164 conversation: Conversation::default(),
165 composer: Composer::new(),
166 exit_state: ExitState::Idle,
167 commands: VecDeque::new(),
168 submission: SubmissionState::default(),
169 browser_opener,
170 clipboard_writer,
171 }
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.refresh_progress();
186 self.take_commands()
187 }
188
189 pub fn take_commands(&mut self) -> Vec<Command> {
190 self.commands.drain(..).collect()
191 }
192
193 pub fn on_command_result(&mut self, result: CommandResult) {
194 match result {
195 CommandResult::AgentCommandAccepted => {}
196 CommandResult::ConfigOptionsUpdated(options) => {
197 self.session.update_config_options(options);
198 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
199 overlay.update_config_options(self.session.config_options());
200 }
201 }
202 CommandResult::ConfigOptionUpdateFailed { error } => {
203 tracing::warn!("set_session_config_option failed: {error}");
204 self.notify(&format!("Failed to update setting: {error}"));
205 }
206 CommandResult::AuthenticationCompleted { method_id } => {
207 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
208 overlay.on_authenticate_complete(&method_id);
209 }
210 }
211 CommandResult::AuthenticationFailed { method_id } => {
212 tracing::warn!("Provider authentication failed for {method_id}");
213 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
214 overlay.on_authenticate_failed(&method_id);
215 }
216 }
217 CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
218 CommandResult::GitDiff(event) => {
219 let Route::GitReview(screen) = &mut self.route else { return };
220 let outputs = screen.on_event(event).into_iter().map(RootOutput::GitReview).collect();
221 self.dispatch_outputs(outputs);
222 }
223 CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
224 CommandResult::ThemesListed(files) => {
225 let entries = build_theme_entries(self.ui.settings.ui(), &files);
226 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
227 overlay.upsert_local_entries(entries);
228 }
229 }
230 CommandResult::ThemeApplied { settings, theme, error } => self.finish_theme_change(settings, theme, error),
231 CommandResult::WorkspaceResolved { cwd, status } => {
232 if self.session.working_dir() == cwd {
233 self.session.set_workspace_status(status);
234 }
235 }
236 CommandResult::SessionsListed(response) => self.open_session_picker(response.sessions),
237 CommandResult::SessionLoaded(loaded) => self.on_loaded_session(loaded),
238 CommandResult::NewSessionCreated(response) => {
239 self.on_new_session(response.session_id, response.config_options.unwrap_or_default());
240 }
241 CommandResult::PromptSearchResults(response) => self.composer.prompt_search_on_results(response),
242 CommandResult::PromptSearchFailed { query, error } => {
243 if let Some(picker) = self.composer.prompt_search_mut() {
244 picker.on_failed(&query, error);
245 }
246 }
247 CommandResult::SessionPreviewLoaded(preview) => {
248 if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
249 picker.on_preview_loaded(preview);
250 }
251 }
252 CommandResult::SessionPreviewFailed { session_id, error } => {
253 if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
254 picker.on_preview_failed(&session_id, error);
255 }
256 }
257 CommandResult::WorkspacesListed(response) => {
258 self.open_overlay(Overlay::Workspaces(WorkspacePicker::new(response.workspaces)));
259 self.session.begin_workspace_picking();
260 }
261 CommandResult::WorkspaceListFailed { error } => {
262 self.abandon_workspace_move(&format!("Failed to list workspaces: {error}"));
263 }
264 CommandResult::WorkspaceMoved(response) => self.on_workspace_moved(response.new_cwd),
265 CommandResult::WorkspaceMoveFailed { error } => {
266 self.abandon_workspace_move(&format!("Workspace move failed: {error}"));
267 }
268 CommandResult::Failed { command, error } => self.on_command_failed(command, &error),
269 }
270 }
271
272 fn on_command_failed(&mut self, command: FailedCommand, error: &str) {
273 match command {
274 FailedCommand::Prompt => {
275 self.finish_prompt(&ToolStatus::Error(format!("failed: {error}")));
276 self.submission.reset();
277 }
278 FailedCommand::LoadSession | FailedCommand::ListWorkspaces | FailedCommand::MoveWorkspace => {
279 self.session.end_workspace_move();
280 }
281 FailedCommand::Other(_) => {}
282 }
283 self.notify(&format!("Failed to {}: {error}", command.describe()));
284 }
285
286 fn start_prompt(&mut self, text: String, content: Option<Vec<acp::ContentBlock>>) {
287 self.conversation.turn_mut().set_prompt_in_flight(true);
288 self.conversation.progress_indicator_mut().prompt_started();
289 self.queue(Command::Agent(AgentCommand::Prompt {
290 session_id: self.session.session_id().clone(),
291 text,
292 content,
293 }));
294 }
295
296 fn queue(&mut self, command: Command) {
297 self.commands.push_back(command);
298 }
299
300 pub fn on_tick(&mut self, now: Instant) {
301 if let ExitState::Confirming(armed_at) = self.exit_state
302 && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
303 {
304 self.exit_state = ExitState::Idle;
305 }
306 if self.conversation.progress_indicator().is_active() {
307 self.conversation.turn_mut().advance_spinner();
308 }
309 self.conversation.progress_indicator_mut().on_tick(now);
310 self.conversation.plan_tracker_mut().on_tick(now);
311 }
312
313 pub fn wants_tick(&self) -> bool {
314 self.waiting_for_response()
315 || self.conversation.any_running()
316 || !self.session.workspace_move_state().is_idle()
317 || self.conversation.turn().is_compaction_active()
318 || self.conversation.progress_indicator().is_active()
319 || self.exit_state.is_confirming()
320 || self.conversation.plan_tracker().has_completed_in_grace_period()
321 }
322
323 pub fn has_navigation(&self) -> bool {
324 self.overlay.is_some() || self.route.is_fullscreen()
325 }
326
327 pub fn has_session_picker(&self) -> bool {
328 matches!(self.overlay, Some(Overlay::Sessions(_)))
329 }
330
331 pub fn has_modal(&self) -> bool {
332 self.overlay.is_some()
333 }
334
335 pub fn full_screen_active(&self) -> bool {
336 self.route.is_fullscreen()
337 }
338
339 pub fn workspace_move_state(&self) -> WorkspaceMoveState {
340 self.session.workspace_move_state()
341 }
342
343 pub fn exit_requested(&self) -> bool {
344 self.exit_state == ExitState::Exiting
345 }
346
347 pub fn conversation_items(&self) -> &[ConversationItem] {
348 self.conversation.items()
349 }
350
351 pub fn conversation_id(&self) -> crate::conversation::ConversationId {
352 self.conversation.id()
353 }
354
355 pub fn composer(&self) -> &Composer {
356 &self.composer
357 }
358
359 pub(crate) fn composer_mut(&mut self) -> &mut Composer {
360 &mut self.composer
361 }
362
363 pub fn config_options(&self) -> &[LocalConfigOption] {
364 self.session.config_options()
365 }
366
367 pub fn auth_methods(&self) -> &[acp::AuthMethod] {
368 self.session.auth_methods()
369 }
370
371 pub(crate) fn content_padding(&self) -> usize {
372 self.ui.content_padding
373 }
374
375 pub fn status_line_model(&self) -> StatusLineModel<'_> {
377 StatusLineModel {
378 settings: &self.ui.status_line,
379 config_options: self.session.config_options(),
380 workspace: self.session.workspace_status(),
381 agent_name: self.session.agent_name(),
382 content_padding: self.ui.content_padding,
383 context_usage: self.conversation.turn().context_usage(),
384 unhealthy_servers: self.session.unhealthy_server_count(),
385 waiting_for_response: self.waiting_for_response(),
386 exit_confirmation: self.exit_state.is_confirming(),
387 }
388 }
389
390 pub fn ui_settings(&self) -> &UiSettings {
391 self.ui.settings.ui()
392 }
393
394 pub fn theme(&self) -> &Theme {
396 &self.ui.theme
397 }
398
399 pub(crate) fn theme_generation(&self) -> Generation {
400 self.ui.theme_generation
401 }
402
403 pub fn waiting_for_response(&self) -> bool {
405 self.conversation.turn().is_prompt_in_flight()
406 }
407
408 pub fn is_agent_busy(&self) -> bool {
410 self.waiting_for_response() || self.conversation.any_running()
411 }
412
413 pub fn progress_indicator(&self) -> &ProgressIndicator {
414 self.conversation.progress_indicator()
415 }
416
417 pub fn exit_confirmation_active(&self) -> bool {
420 self.exit_state.is_confirming()
421 }
422
423 pub(crate) fn spinner_tick(&self) -> usize {
424 self.conversation.turn().spinner_tick()
425 }
426
427 pub fn plan_entries(&self) -> Vec<acp::PlanEntry> {
428 self.conversation.plan_tracker().current_entries()
429 }
430
431 pub fn has_plan(&self) -> bool {
434 self.conversation.plan_tracker().has_entries()
435 }
436
437 fn reset_conversation(&mut self) {
439 self.reset_turn_state();
440 self.submission.reset();
441 self.conversation.clear();
442 }
443
444 fn reset_turn_state(&mut self) {
449 self.conversation.reset_feature_state();
452 }
453
454 fn refresh_progress(&mut self) {
455 let override_phase = match self.session.workspace_move_state() {
456 WorkspaceMoveState::Moving => Some(ProgressPhase::MovingWorkspace),
457 WorkspaceMoveState::LoadingSession => Some(ProgressPhase::LoadingSession),
458 _ if self.conversation.turn().is_compaction_active() => Some(ProgressPhase::Compacting),
459 _ => None,
460 };
461 let interruptible = self.is_agent_busy();
462 self.conversation.progress_indicator_mut().refresh(override_phase, interruptible);
463 }
464
465 fn return_to_conversation(&mut self) {
466 self.close_overlay();
467 self.route = Route::Conversation;
468 }
469}