use crate::command::{Command, CommandResult, FailedCommand};
use crate::app::keybindings::Keybindings;
use crate::app::message::Message;
use crate::conversation::items::{Conversation, ConversationItem};
use crate::conversation::progress_indicator::{ProgressIndicator, ProgressPhase};
use crate::conversation::status_line::StatusLineModel;
use crate::session::platform::{
BrowserOpener, ClipboardWriter, default_browser_opener, default_clipboard_writer,
};
use crate::session::session_config_view::LocalConfigOption;
use crate::session::session_model::SessionModel;
use crate::session::workspace_status::WorkspaceStatus;
use crate::settings::{
ResolvedStatusLineSettings, SettingsModel, UiSettings, resolve_content_padding, resolve_status_line_settings,
};
use crate::surfaces::composer::Composer;
use crate::surfaces::input::RootOutput;
use crate::surfaces::picker::CommandEntry;
use crate::view::generation::Generation;
use crate::theme::Theme;
use acp_utils::client::AcpEvent;
use acp_utils::notifications::AetherCapabilities;
use agent_client_protocol::schema::v1::{self as acp, SessionId};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::Instant;
use tokio::sync::mpsc;
pub mod message;
mod navigation;
pub use crate::session::session_model::WorkspaceMoveState;
pub use navigation::{Overlay, Route};
mod acp_reducer;
mod config;
mod input;
mod keybindings;
mod session;
mod submission;
use config::build_theme_entries;
use input::CTRL_C_CONFIRM_WINDOW;
use session::builtin_commands;
use submission::SubmissionState;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ExitState {
#[default]
Idle,
Confirming(Instant),
Exiting,
}
impl ExitState {
fn is_confirming(&self) -> bool {
matches!(self, ExitState::Confirming(_))
}
}
pub struct App {
session: SessionModel,
ui: UiConfig,
available_commands: Vec<CommandEntry>,
route: Route,
overlay: Option<Overlay>,
conversation: Conversation,
composer: Composer,
exit_state: ExitState,
commands: VecDeque<Command>,
submission: SubmissionState,
browser_opener: BrowserOpener,
clipboard_writer: ClipboardWriter,
}
struct UiConfig {
settings: SettingsModel,
keybindings: Keybindings,
content_padding: usize,
status_line: ResolvedStatusLineSettings,
theme: Theme,
theme_generation: Generation,
}
pub struct AppConfig {
pub session_id: SessionId,
pub agent_name: String,
pub workspace_status: WorkspaceStatus,
pub prompt_capabilities: acp::PromptCapabilities,
pub session_capabilities: acp::SessionCapabilities,
pub config_options: Vec<acp::SessionConfigOption>,
pub auth_methods: Vec<acp::AuthMethod>,
pub working_dir: PathBuf,
pub settings: UiSettings,
pub browser_opener: BrowserOpener,
pub clipboard_writer: ClipboardWriter,
}
impl App {
pub(crate) fn from_session(
session: crate::session::Session,
settings: UiSettings,
) -> (Self, mpsc::UnboundedReceiver<AcpEvent>, acp_utils::client::AcpPromptHandle) {
let crate::session::Session {
session_id,
agent_name,
prompt_capabilities,
session_capabilities,
config_options,
auth_methods,
event_rx,
prompt_handle,
working_dir,
workspace_status,
} = session;
let mut app = Self::new(AppConfig {
session_id,
agent_name,
workspace_status,
prompt_capabilities,
session_capabilities,
config_options,
auth_methods,
working_dir,
settings,
browser_opener: default_browser_opener(),
clipboard_writer: default_clipboard_writer(),
});
app.queue(Command::ResolveWorkspace { cwd: app.session.working_dir().to_path_buf() });
(app, event_rx, prompt_handle)
}
pub fn new(config: AppConfig) -> Self {
let ui = UiConfig {
content_padding: resolve_content_padding(&config.settings),
status_line: resolve_status_line_settings(&config.settings),
keybindings: Keybindings::from_settings(&config.settings),
theme: Theme::load(&config.settings),
theme_generation: Generation::default(),
settings: SettingsModel::new(config.settings.clone()),
};
let capabilities = AetherCapabilities::from_meta(config.session_capabilities.meta.as_ref());
let initial_commands = builtin_commands(&capabilities);
let browser_opener = config.browser_opener.clone();
let clipboard_writer = config.clipboard_writer.clone();
Self {
session: SessionModel::from_config(config, capabilities),
ui,
available_commands: initial_commands,
route: Route::Conversation,
overlay: None,
conversation: Conversation::default(),
composer: Composer::new(),
exit_state: ExitState::Idle,
commands: VecDeque::new(),
submission: SubmissionState::default(),
browser_opener,
clipboard_writer,
}
}
pub fn update(&mut self, message: Message) -> Vec<Command> {
match message {
Message::Terminal(event) => self.on_terminal_event(event),
Message::Agent(event) => self.on_acp_event(*event),
Message::CommandFinished(result) => self.on_command_result(result),
Message::Tick(now) => self.on_tick(now),
}
self.refresh_progress();
self.take_commands()
}
pub fn take_commands(&mut self) -> Vec<Command> {
self.commands.drain(..).collect()
}
pub fn on_command_result(&mut self, result: CommandResult) {
match result {
CommandResult::FilesIndexed { request_id, files } => self.composer.on_files_indexed(request_id, files),
CommandResult::GitDiff(event) => {
let Route::GitReview(screen) = &mut self.route else { return };
let outputs = screen.on_event(event).into_iter().map(RootOutput::GitReview).collect();
self.dispatch_outputs(outputs);
}
CommandResult::SubmissionPrepared(outcome) => self.finish_submission(outcome),
CommandResult::ThemesListed(files) => {
let entries = build_theme_entries(self.ui.settings.ui(), &files);
if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
overlay.upsert_local_entries(entries);
}
}
CommandResult::ThemeApplied { settings, theme, error } => self.finish_theme_change(settings, theme, error),
CommandResult::WorkspaceResolved { cwd, status } => {
if self.session.working_dir() == cwd {
self.session.set_workspace_status(status);
}
}
CommandResult::PromptSearchFailed { query, error } => {
if let Some(picker) = self.composer.prompt_search_mut() {
picker.on_failed(&query, error);
}
}
CommandResult::Failed { command, error } => self.on_command_failed(command, &error),
}
}
fn on_command_failed(&mut self, command: FailedCommand, error: &str) {
match command {
FailedCommand::Prompt => {
self.conversation.turn_mut().set_prompt_in_flight(false);
self.conversation.progress_indicator_mut().prompt_finished();
self.submission.reset();
}
FailedCommand::LoadSession => {
self.session.clear_loads();
self.session.end_workspace_move();
}
FailedCommand::ListWorkspaces | FailedCommand::MoveWorkspace => {
self.session.end_workspace_move();
}
FailedCommand::Other(_) => {}
}
self.notify(&format!("Failed to {}: {error}", command.describe()));
}
fn queue(&mut self, command: Command) {
self.commands.push_back(command);
}
pub fn on_tick(&mut self, now: Instant) {
if let ExitState::Confirming(armed_at) = self.exit_state
&& now.duration_since(armed_at) > CTRL_C_CONFIRM_WINDOW
{
self.exit_state = ExitState::Idle;
}
if self.conversation.progress_indicator().is_active() {
self.conversation.turn_mut().advance_spinner();
}
self.conversation.progress_indicator_mut().on_tick(now);
self.conversation.plan_tracker_mut().on_tick(now);
}
pub fn wants_tick(&self) -> bool {
self.waiting_for_response()
|| self.conversation.any_running()
|| !self.session.workspace_move_state().is_idle()
|| self.conversation.turn().is_compaction_active()
|| self.conversation.progress_indicator().is_active()
|| self.exit_state.is_confirming()
|| self.conversation.plan_tracker().has_completed_in_grace_period()
}
pub fn has_navigation(&self) -> bool {
self.overlay.is_some() || self.route.is_fullscreen()
}
pub fn has_session_picker(&self) -> bool {
matches!(self.overlay, Some(Overlay::Sessions(_)))
}
pub fn has_modal(&self) -> bool {
self.overlay.is_some()
}
pub fn full_screen_active(&self) -> bool {
self.route.is_fullscreen()
}
pub fn workspace_move_state(&self) -> WorkspaceMoveState {
self.session.workspace_move_state()
}
pub fn exit_requested(&self) -> bool {
self.exit_state == ExitState::Exiting
}
pub fn conversation_items(&self) -> &[ConversationItem] {
self.conversation.items()
}
pub fn conversation_id(&self) -> crate::conversation::ConversationId {
self.conversation.id()
}
pub fn composer(&self) -> &Composer {
&self.composer
}
pub(crate) fn composer_mut(&mut self) -> &mut Composer {
&mut self.composer
}
pub fn config_options(&self) -> &[LocalConfigOption] {
self.session.config_options()
}
pub fn auth_methods(&self) -> &[acp::AuthMethod] {
self.session.auth_methods()
}
pub(crate) fn content_padding(&self) -> usize {
self.ui.content_padding
}
pub fn status_line_model(&self) -> StatusLineModel<'_> {
StatusLineModel {
settings: &self.ui.status_line,
config_options: self.session.config_options(),
workspace: self.session.workspace_status(),
agent_name: self.session.agent_name(),
content_padding: self.ui.content_padding,
context_usage: self.conversation.turn().context_usage(),
unhealthy_servers: self.session.unhealthy_server_count(),
waiting_for_response: self.waiting_for_response(),
exit_confirmation: self.exit_state.is_confirming(),
}
}
pub fn ui_settings(&self) -> &UiSettings {
self.ui.settings.ui()
}
pub fn theme(&self) -> &Theme {
&self.ui.theme
}
pub(crate) fn theme_generation(&self) -> Generation {
self.ui.theme_generation
}
pub fn waiting_for_response(&self) -> bool {
self.conversation.turn().is_prompt_in_flight()
}
pub fn is_agent_busy(&self) -> bool {
self.waiting_for_response() || self.conversation.any_running()
}
pub fn progress_indicator(&self) -> &ProgressIndicator {
self.conversation.progress_indicator()
}
pub fn exit_confirmation_active(&self) -> bool {
self.exit_state.is_confirming()
}
pub(crate) fn spinner_tick(&self) -> usize {
self.conversation.turn().spinner_tick()
}
pub fn plan_entries(&self) -> Vec<acp::PlanEntry> {
self.conversation.plan_tracker().current_entries()
}
pub fn has_plan(&self) -> bool {
self.conversation.plan_tracker().has_entries()
}
fn reset_conversation(&mut self) {
self.reset_turn_state();
self.submission.reset();
self.conversation.clear();
}
fn reset_turn_state(&mut self) {
self.conversation.reset_feature_state();
}
fn refresh_progress(&mut self) {
let override_phase = match self.session.workspace_move_state() {
WorkspaceMoveState::Moving => Some(ProgressPhase::MovingWorkspace),
WorkspaceMoveState::LoadingSession => Some(ProgressPhase::LoadingSession),
_ if self.conversation.turn().is_compaction_active() => Some(ProgressPhase::Compacting),
_ => None,
};
let interruptible = self.is_agent_busy();
self.conversation.progress_indicator_mut().refresh(override_phase, interruptible);
}
fn return_to_conversation(&mut self) {
self.close_overlay();
self.route = Route::Conversation;
}
}