1use crate::command::{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::session::platform::{
8 BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer,
9};
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::input::RootOutput;
18use crate::surfaces::picker::CommandEntry;
19use crate::view::generation::Generation;
20use crate::theme::Theme;
21use acp_utils::client::AcpEvent;
22use acp_utils::notifications::AetherCapabilities;
23use agent_client_protocol::schema::v1::{self as acp, SessionId};
24use std::collections::VecDeque;
25use std::path::PathBuf;
26use std::time::Instant;
27use tokio::sync::mpsc;
28
29pub mod message;
30mod navigation;
31
32pub use crate::session::session_model::WorkspaceMoveState;
33pub use navigation::{Overlay, Route};
34
35mod acp_reducer;
36mod config;
37mod input;
38mod keybindings;
39mod session;
40mod submission;
41use config::build_theme_entries;
42use input::CTRL_C_CONFIRM_WINDOW;
43use session::builtin_commands;
44use submission::SubmissionState;
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum ExitState {
48 #[default]
49 Idle,
50 Confirming(Instant),
51 Exiting,
52}
53
54impl ExitState {
55 fn is_confirming(&self) -> bool {
56 matches!(self, ExitState::Confirming(_))
57 }
58}
59
60pub struct App {
63 session: SessionModel,
64 ui: UiConfig,
65 available_commands: Vec<CommandEntry>,
66 route: Route,
67 overlay: Option<Overlay>,
68 conversation: Conversation,
69 composer: Composer,
70 exit_state: ExitState,
71 commands: VecDeque<Command>,
73 submission: SubmissionState,
74 browser_opener: BrowserOpener,
75 clipboard_writer: ClipboardWriter,
76}
77
78struct UiConfig {
80 settings: SettingsModel,
81 keybindings: Keybindings,
82 content_padding: usize,
83 status_line: ResolvedStatusLineSettings,
84 theme: Theme,
85 theme_generation: Generation,
86}
87
88pub struct AppConfig {
89 pub session_id: SessionId,
90 pub agent_name: String,
91 pub workspace_status: WorkspaceStatus,
92 pub prompt_capabilities: acp::PromptCapabilities,
93 pub session_capabilities: acp::SessionCapabilities,
94 pub config_options: Vec<acp::SessionConfigOption>,
95 pub auth_methods: Vec<acp::AuthMethod>,
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::AcpPromptHandle) {
114 let crate::session::Session {
115 session_id,
116 agent_name,
117 prompt_capabilities,
118 session_capabilities,
119 config_options,
120 auth_methods,
121 event_rx,
122 prompt_handle,
123 working_dir,
124 workspace_status,
125 } = session;
126 let mut app = Self::new(AppConfig {
127 session_id,
128 agent_name,
129 workspace_status,
130 prompt_capabilities,
131 session_capabilities,
132 config_options,
133 auth_methods,
134 working_dir,
135 settings,
136 browser_opener: default_browser_opener(),
137 clipboard_writer: default_clipboard_writer(),
138 });
139 app.queue(Command::ResolveWorkspace { cwd: app.session.working_dir().to_path_buf() });
140 (app, event_rx, prompt_handle)
141 }
142
143 pub fn new(config: AppConfig) -> Self {
144 let ui = UiConfig {
145 content_padding: resolve_content_padding(&config.settings),
146 status_line: resolve_status_line_settings(&config.settings),
147 keybindings: Keybindings::from_settings(&config.settings),
148 theme: Theme::load(&config.settings),
149 theme_generation: Generation::default(),
150 settings: SettingsModel::new(config.settings.clone()),
151 };
152 let capabilities = AetherCapabilities::from_meta(config.session_capabilities.meta.as_ref());
153 let initial_commands = builtin_commands(&capabilities);
154 let browser_opener = config.browser_opener.clone();
155 let clipboard_writer = config.clipboard_writer.clone();
156 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::default(),
163 composer: Composer::new(),
164 exit_state: ExitState::Idle,
165 commands: VecDeque::new(),
166 submission: SubmissionState::default(),
167 browser_opener,
168 clipboard_writer,
169 }
170 }
171
172 pub fn update(&mut self, message: Message) -> Vec<Command> {
176 match message {
177 Message::Terminal(event) => self.on_terminal_event(event),
178 Message::Agent(event) => self.on_acp_event(*event),
179 Message::CommandFinished(result) => self.on_command_result(result),
180 Message::Tick(now) => self.on_tick(now),
181 }
182
183 self.refresh_progress();
184 self.take_commands()
185 }
186
187 pub fn take_commands(&mut self) -> Vec<Command> {
188 self.commands.drain(..).collect()
189 }
190
191 pub fn on_command_result(&mut self, result: CommandResult) {
192 match result {
193 CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
194 CommandResult::GitDiff(event) => {
195 let Route::GitReview(screen) = &mut self.route else { return };
196 let outputs = screen.on_event(event).into_iter().map(RootOutput::GitReview).collect();
197 self.dispatch_outputs(outputs);
198 }
199 CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
200 CommandResult::ThemesListed(files) => {
201 let entries = build_theme_entries(self.ui.settings.ui(), &files);
202 if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
203 overlay.upsert_local_entries(entries);
204 }
205 }
206 CommandResult::ThemeApplied { settings, theme, error } => self.finish_theme_change(settings, theme, error),
207 CommandResult::WorkspaceResolved { cwd, status } => {
208 if self.session.working_dir() == cwd {
209 self.session.set_workspace_status(status);
210 }
211 }
212 CommandResult::PromptSearchFailed { query, error } => {
213 if let Some(picker) = self.composer.prompt_search_mut() {
214 picker.on_failed(&query, error);
215 }
216 }
217 CommandResult::Failed { command, error } => self.on_command_failed(command, &error),
218 }
219 }
220
221 fn on_command_failed(&mut self, command: FailedCommand, error: &str) {
222 match command {
223 FailedCommand::Prompt => {
224 self.conversation.turn_mut().set_prompt_in_flight(false);
225 self.conversation.progress_indicator_mut().prompt_finished();
226 self.submission.reset();
227 }
228 FailedCommand::LoadSession => {
229 self.session.clear_loads();
230 self.session.end_workspace_move();
231 }
232 FailedCommand::ListWorkspaces | FailedCommand::MoveWorkspace => {
233 self.session.end_workspace_move();
234 }
235 FailedCommand::Other(_) => {}
236 }
237 self.notify(&format!("Failed to {}: {error}", command.describe()));
238 }
239
240 fn queue(&mut self, command: Command) {
241 self.commands.push_back(command);
242 }
243
244 pub fn on_tick(&mut self, now: Instant) {
245 if let ExitState::Confirming(armed_at) = self.exit_state
246 && now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
247 {
248 self.exit_state = ExitState::Idle;
249 }
250 if self.conversation.progress_indicator().is_active() {
251 self.conversation.turn_mut().advance_spinner();
252 }
253 self.conversation.progress_indicator_mut().on_tick(now);
254 self.conversation.plan_tracker_mut().on_tick(now);
255 }
256
257 pub fn wants_tick(&self) -> bool {
258 self.waiting_for_response()
259 || self.conversation.any_running()
260 || !self.session.workspace_move_state().is_idle()
261 || self.conversation.turn().is_compaction_active()
262 || self.conversation.progress_indicator().is_active()
263 || self.exit_state.is_confirming()
264 || self.conversation.plan_tracker().has_completed_in_grace_period()
265 }
266
267 pub fn has_navigation(&self) -> bool {
268 self.overlay.is_some() || self.route.is_fullscreen()
269 }
270
271 pub fn has_session_picker(&self) -> bool {
272 matches!(self.overlay, Some(Overlay::Sessions(_)))
273 }
274
275 pub fn has_modal(&self) -> bool {
276 self.overlay.is_some()
277 }
278
279 pub fn full_screen_active(&self) -> bool {
280 self.route.is_fullscreen()
281 }
282
283 pub fn workspace_move_state(&self) -> WorkspaceMoveState {
284 self.session.workspace_move_state()
285 }
286
287 pub fn exit_requested(&self) -> bool {
288 self.exit_state == ExitState::Exiting
289 }
290
291 pub fn conversation_items(&self) -> &[ConversationItem] {
292 self.conversation.items()
293 }
294
295 pub fn conversation_id(&self) -> crate::conversation::ConversationId {
296 self.conversation.id()
297 }
298
299 pub fn composer(&self) -> &Composer {
300 &self.composer
301 }
302
303 pub(crate) fn composer_mut(&mut self) -> &mut Composer {
304 &mut self.composer
305 }
306
307 pub fn config_options(&self) -> &[LocalConfigOption] {
308 self.session.config_options()
309 }
310
311 pub fn auth_methods(&self) -> &[acp::AuthMethod] {
312 self.session.auth_methods()
313 }
314
315 pub(crate) fn content_padding(&self) -> usize {
316 self.ui.content_padding
317 }
318
319 pub fn status_line_model(&self) -> StatusLineModel<'_> {
321 StatusLineModel {
322 settings: &self.ui.status_line,
323 config_options: self.session.config_options(),
324 workspace: self.session.workspace_status(),
325 agent_name: self.session.agent_name(),
326 content_padding: self.ui.content_padding,
327 context_usage: self.conversation.turn().context_usage(),
328 unhealthy_servers: self.session.unhealthy_server_count(),
329 waiting_for_response: self.waiting_for_response(),
330 exit_confirmation: self.exit_state.is_confirming(),
331 }
332 }
333
334 pub fn ui_settings(&self) -> &UiSettings {
335 self.ui.settings.ui()
336 }
337
338 pub fn theme(&self) -> &Theme {
340 &self.ui.theme
341 }
342
343 pub(crate) fn theme_generation(&self) -> Generation {
344 self.ui.theme_generation
345 }
346
347 pub fn waiting_for_response(&self) -> bool {
349 self.conversation.turn().is_prompt_in_flight()
350 }
351
352 pub fn is_agent_busy(&self) -> bool {
354 self.waiting_for_response() || self.conversation.any_running()
355 }
356
357 pub fn progress_indicator(&self) -> &ProgressIndicator {
358 self.conversation.progress_indicator()
359 }
360
361 pub fn exit_confirmation_active(&self) -> bool {
364 self.exit_state.is_confirming()
365 }
366
367 pub(crate) fn spinner_tick(&self) -> usize {
368 self.conversation.turn().spinner_tick()
369 }
370
371 pub fn plan_entries(&self) -> Vec<acp::PlanEntry> {
372 self.conversation.plan_tracker().current_entries()
373 }
374
375 pub fn has_plan(&self) -> bool {
378 self.conversation.plan_tracker().has_entries()
379 }
380
381 fn reset_conversation(&mut self) {
383 self.reset_turn_state();
384 self.submission.reset();
385 self.conversation.clear();
386 }
387
388 fn reset_turn_state(&mut self) {
393 self.conversation.reset_feature_state();
396 }
397
398 fn refresh_progress(&mut self) {
399 let override_phase = match self.session.workspace_move_state() {
400 WorkspaceMoveState::Moving => Some(ProgressPhase::MovingWorkspace),
401 WorkspaceMoveState::LoadingSession => Some(ProgressPhase::LoadingSession),
402 _ if self.conversation.turn().is_compaction_active() => Some(ProgressPhase::Compacting),
403 _ => None,
404 };
405 let interruptible = self.is_agent_busy();
406 self.conversation.progress_indicator_mut().refresh(override_phase, interruptible);
407 }
408
409 fn return_to_conversation(&mut self) {
410 self.close_overlay();
411 self.route = Route::Conversation;
412 }
413}