diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 23:58:17 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 00:00:16 +0300 |
| commit | fb93e3747544afc3288d7b5e831d5ca3dd5c3768 (patch) | |
| tree | 95ed3208395ebe68dc49fbd7d8edb7e16597ba49 /src | |
| parent | 2bebac461b1d5d6a1ec42d70c2338cf27eee8c56 (diff) | |
feat: surface agent integrations in onboarding
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/mod.rs | 11 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 95 | ||||
| -rw-r--r-- | src/app/mod.rs | 48 | ||||
| -rw-r--r-- | src/app/state.rs | 16 | ||||
| -rw-r--r-- | src/integration/mod.rs | 138 | ||||
| -rw-r--r-- | src/ui.rs | 2 | ||||
| -rw-r--r-- | src/ui/onboarding.rs | 6 | ||||
| -rw-r--r-- | src/ui/settings.rs | 162 |
8 files changed, 447 insertions, 31 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 18dd8856..ae8fb12b 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -37,7 +37,7 @@ pub(crate) use self::{ handle_keybind_help_key, handle_rename_key, handle_resize_key, }, navigate::terminal_direct_navigation_action, - settings::open_settings, + settings::open_settings_at, }; use self::{ modal::{ @@ -181,6 +181,7 @@ impl App { } let previous_agent_panel_scope = self.state.agent_panel_scope; + let previous_settings_section = self.state.settings.section; if let Some(action) = self.state.handle_mouse(mouse) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), @@ -189,8 +190,16 @@ impl App { SettingsAction::SaveAgentBorderLabels(enabled) => { self.save_agent_border_labels(enabled) } + SettingsAction::InstallRecommendedIntegrations => { + self.install_recommended_integrations() + } } } + if previous_settings_section != crate::app::state::SettingsSection::Integrations + && self.state.settings.section == crate::app::state::SettingsSection::Integrations + { + self.refresh_integration_recommendations(); + } if self.state.agent_panel_scope != previous_agent_panel_scope { self.save_agent_panel_scope(self.state.agent_panel_scope); } diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 79fdf5d8..400b2796 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -17,10 +17,12 @@ pub(super) enum SettingsAction { SaveSound(bool), SaveToastDelivery(ToastDelivery), SaveAgentBorderLabels(bool), + InstallRecommendedIntegrations, } impl App { pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) { + let previous_section = self.state.settings.section; if let Some(action) = update_settings_state(&mut self.state, key) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), @@ -29,8 +31,16 @@ impl App { SettingsAction::SaveAgentBorderLabels(enabled) => { self.save_agent_border_labels(enabled) } + SettingsAction::InstallRecommendedIntegrations => { + self.install_recommended_integrations() + } } } + if previous_section != SettingsSection::Integrations + && self.state.settings.section == SettingsSection::Integrations + { + self.refresh_integration_recommendations(); + } } } @@ -84,6 +94,13 @@ fn cancel_settings(state: &mut AppState) { super::modal::leave_modal(state); } +fn integrations_need_install(state: &AppState) -> bool { + state + .integration_recommendations + .iter() + .any(crate::integration::IntegrationRecommendation::needs_install) +} + fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { match state.settings.section { SettingsSection::Theme => { @@ -93,6 +110,10 @@ fn apply_settings(state: &mut AppState) -> Option<SettingsAction> { super::modal::leave_modal(state); Some(SettingsAction::SaveTheme(theme_name)) } + SettingsSection::Integrations if integrations_need_install(state) => { + Some(SettingsAction::InstallRecommendedIntegrations) + } + SettingsSection::Integrations => None, _ => { super::modal::leave_modal(state); None @@ -187,8 +208,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti state.settings.list.selected = toast_delivery_index(state.toast_delivery()); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); + state.settings.section = SettingsSection::Integrations; + state.settings.list.selected = 0; } _ => { if let Some(super::modal::ModalAction::Close) = @@ -198,16 +219,44 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } } }, + SettingsSection::Integrations => match key.code { + KeyCode::Enter | KeyCode::Char(' ') if integrations_need_install(state) => { + return Some(SettingsAction::InstallRecommendedIntegrations); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::PaneLabels; + state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) { + Some(super::modal::ModalAction::Apply) => return apply_settings(state), + Some(super::modal::ModalAction::Close) => cancel_settings(state), + _ => {} + }, + }, } None } pub(crate) fn open_settings(state: &mut AppState) { + open_settings_at(state, SettingsSection::Theme); +} + +pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { state.settings.original_palette = Some(state.palette.clone()); state.settings.original_theme = Some(state.theme_name.clone()); - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); + state.settings.section = section; + state.settings.list.selected = match section { + SettingsSection::Theme => current_theme_index(&state.theme_name), + SettingsSection::Sound => usize::from(!state.sound_enabled()), + SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), + SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), + SettingsSection::Integrations => 0, + }; state.mode = Mode::Settings; } @@ -290,6 +339,7 @@ impl AppState { None } } + SettingsSection::Integrations => None, } } @@ -305,6 +355,7 @@ impl AppState { SettingsSection::PaneLabels => { usize::from(!self.agent_border_labels_enabled()) } + SettingsSection::Integrations => 0, }); return None; } @@ -327,19 +378,19 @@ impl AppState { let enabled = idx == 0; Some(SettingsAction::SaveAgentBorderLabels(enabled)) } + SettingsSection::Integrations => None, }; } let inner = self.settings_inner_rect(); - let (apply, close) = crate::ui::settings_button_rects(inner); - match super::modal::modal_action_from_buttons( - mouse.column, - mouse.row, - &[ - (apply, super::modal::ModalAction::Apply), - (close, super::modal::ModalAction::Close), - ], - ) { + let show_primary = crate::ui::settings_show_primary_action(self); + let (apply, close) = + crate::ui::settings_button_rects(inner, self.settings.section, show_primary); + let mut buttons = vec![(close, super::modal::ModalAction::Close)]; + if let Some(apply) = apply { + buttons.insert(0, (apply, super::modal::ModalAction::Apply)); + } + match super::modal::modal_action_from_buttons(mouse.column, mouse.row, &buttons) { Some(super::modal::ModalAction::Apply) => apply_settings(self), Some(super::modal::ModalAction::Close) => { cancel_settings(self); @@ -414,6 +465,24 @@ mod tests { } #[test] + fn integrations_enter_does_nothing_when_nothing_needs_install() { + let mut state = state_with_workspaces(&["test"]); + open_settings_at(&mut state, SettingsSection::Integrations); + + let enter_action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + assert_eq!(enter_action, None); + + let space_action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), + ); + assert_eq!(space_action, None); + } + + #[test] fn settings_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); open_settings(&mut app.state); diff --git a/src/app/mod.rs b/src/app/mod.rs index 57a30633..39cf0c13 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -439,6 +439,8 @@ impl App { original_palette: None, original_theme: None, }, + integration_recommendations: crate::integration::integration_recommendations(), + integration_install_messages: Vec::new(), global_menu: state::MenuListState::new(0), host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), session_dirty: false, @@ -735,7 +737,47 @@ impl App { pub(crate) fn open_settings_from_onboarding(&mut self) { self.mark_onboarding_complete(); - crate::app::input::open_settings(&mut self.state); + self.refresh_integration_recommendations(); + crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations); + } + + pub(crate) fn refresh_integration_recommendations(&mut self) { + self.state.integration_recommendations = crate::integration::integration_recommendations(); + } + + pub(crate) fn install_recommended_integrations(&mut self) { + let targets = self + .state + .integration_recommendations + .iter() + .filter(|recommendation| recommendation.needs_install()) + .map(|recommendation| recommendation.target) + .collect::<Vec<_>>(); + + self.state.integration_install_messages.clear(); + if targets.is_empty() { + self.state + .integration_install_messages + .push("all detected integrations are current".to_string()); + return; + } + + for target in targets { + let label = crate::integration::integration_target_label(target); + match crate::integration::install_target(target) { + Ok(_) => self + .state + .integration_install_messages + .push(format!("installed {label}")), + Err(err) => self + .state + .integration_install_messages + .push(format!("{label}: {err}")), + } + } + + self.state.integration_recommendations = crate::integration::integration_recommendations(); + self.state.mark_session_dirty(); } pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport { @@ -2618,6 +2660,10 @@ mod tests { app.route_client_input(b"\r".to_vec()); assert_eq!(app.state.mode, Mode::Settings); + assert_eq!( + app.state.settings.section, + state::SettingsSection::Integrations + ); } #[test] diff --git a/src/app/state.rs b/src/app/state.rs index 34be58d2..746474df 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -620,10 +620,17 @@ pub enum SettingsSection { Sound, Toast, PaneLabels, + Integrations, } impl SettingsSection { - pub const ALL: &[Self] = &[Self::Theme, Self::Sound, Self::Toast, Self::PaneLabels]; + pub const ALL: &[Self] = &[ + Self::Theme, + Self::Sound, + Self::Toast, + Self::PaneLabels, + Self::Integrations, + ]; pub fn label(self) -> &'static str { match self { @@ -631,6 +638,7 @@ impl SettingsSection { Self::Sound => "sound", Self::Toast => "toasts", Self::PaneLabels => "pane labels", + Self::Integrations => "integrations", } } } @@ -972,6 +980,10 @@ pub struct AppState { pub theme_name: String, /// Settings panel state. pub settings: SettingsState, + /// Cached integration recommendations for onboarding/settings UI. + pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>, + /// Result messages from the latest integration install action. + pub integration_install_messages: Vec<String>, /// Highlight state for the bottom-right global launcher menu. pub global_menu: MenuListState, /// Resolved host terminal default colors for theming embedded panes. @@ -1279,6 +1291,8 @@ impl AppState { original_palette: None, original_theme: None, }, + integration_recommendations: Vec::new(), + integration_install_messages: Vec::new(), global_menu: MenuListState::new(0), host_terminal_theme: TerminalTheme::default(), session_dirty: false, diff --git a/src/integration/mod.rs b/src/integration/mod.rs index c484a8f1..ae3756b0 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -73,6 +73,32 @@ pub(crate) enum IntegrationStatusKind { Outdated, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IntegrationRecommendation { + pub target: crate::api::schema::IntegrationTarget, + pub label: &'static str, + pub command: &'static str, + pub available: bool, + pub path: PathBuf, + pub state: IntegrationStatusKind, +} + +impl IntegrationRecommendation { + pub fn needs_install(&self) -> bool { + self.state == IntegrationStatusKind::Outdated + || (self.available && self.state == IntegrationStatusKind::NotInstalled) + } + + pub fn status_label(&self) -> &'static str { + match (self.available, self.state) { + (_, IntegrationStatusKind::Current) => "installed", + (_, IntegrationStatusKind::Outdated) => "update available", + (true, IntegrationStatusKind::NotInstalled) => "available", + (false, IntegrationStatusKind::NotInstalled) => "not found", + } + } +} + #[derive(Debug)] pub(crate) struct PiUninstallResult { pub extension_path: PathBuf, @@ -311,6 +337,47 @@ pub(crate) fn integration_target_label( } } +fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> &'static str { + match target { + crate::api::schema::IntegrationTarget::Pi => "pi", + crate::api::schema::IntegrationTarget::Claude => "claude", + crate::api::schema::IntegrationTarget::Codex => "codex", + crate::api::schema::IntegrationTarget::Opencode => "opencode", + crate::api::schema::IntegrationTarget::Hermes => "hermes", + } +} + +fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { + command_available(integration_target_command(target)) +} + +fn command_available(command: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| executable_file_exists(&dir.join(command))) +} + +fn executable_file_exists(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} + pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> { integration_specs() .into_iter() @@ -320,6 +387,25 @@ pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> { .collect() } +pub(crate) fn integration_recommendations() -> Vec<IntegrationRecommendation> { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + let path = path.ok()?; + let status = integration_status_at(target, path.clone(), expected_version); + Some(IntegrationRecommendation { + target, + label: integration_target_label(target), + command: integration_target_command(target), + available: integration_target_available(target) + || status.state != IntegrationStatusKind::NotInstalled, + path, + state: status.state, + }) + }) + .collect() +} + fn outdated_installed_integrations() -> Vec<IntegrationStatus> { installed_integration_statuses() .into_iter() @@ -1363,6 +1449,58 @@ mod tests { } #[test] + #[cfg(unix)] + fn command_available_requires_executable_file_on_path() { + use std::os::unix::fs::PermissionsExt; + + let _lock = integration_env_lock(); + let base = unique_base(); + let bin = base.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let original_path = std::env::var_os("PATH"); + std::env::set_var("PATH", &bin); + + let command = bin.join("claude"); + fs::write(&command, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&command, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(!command_available("claude")); + + fs::set_permissions(&command, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(command_available("claude")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn integration_recommendation_installs_available_or_outdated_targets() { + let mut recommendation = IntegrationRecommendation { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude", + command: "claude", + available: false, + path: PathBuf::from("/tmp/herdr-agent-state.sh"), + state: IntegrationStatusKind::NotInstalled, + }; + assert!(!recommendation.needs_install()); + + recommendation.available = true; + assert!(recommendation.needs_install()); + + recommendation.available = false; + recommendation.state = IntegrationStatusKind::Outdated; + assert!(recommendation.needs_install()); + + recommendation.available = true; + recommendation.state = IntegrationStatusKind::Current; + assert!(!recommendation.needs_install()); + } + + #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { let _lock = integration_env_lock(); let base = unique_base(); @@ -49,7 +49,7 @@ use self::status::{render_config_diagnostic, render_toast_notification, toast_no use self::tabs::render_tab_bar; pub(crate) use self::{ dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects}, - settings::settings_button_rects, + settings::{settings_button_rects, settings_show_primary_action}, sidebar::{ agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 4a1d4f57..104f737f 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -94,6 +94,12 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { ]); frame.render_widget(Paragraph::new(key_line), content_rows[2]); + frame.render_widget( + Paragraph::new(" next: install optional agent integrations for more reliable state") + .style(Style::default().fg(app.palette.overlay1)), + content_rows[3], + ); + let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default()); render_action_button( frame, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index abc103d1..c191eef2 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -118,22 +118,30 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R app.settings.list.selected, ); } + SettingsSection::Integrations => { + render_settings_integrations(app, frame, content_area); + } } if let Some(footer_area) = stack.footer { let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) .areas::<2>(footer_area); - let (apply_rect, close_rect) = settings_button_rects(inner); - render_action_button( - frame, - apply_rect, - Some("↵"), - "apply", - Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.accent) - .add_modifier(Modifier::BOLD), - ); + let primary_label = settings_primary_button_label(app.settings.section); + let show_primary = settings_show_primary_action(app); + let (apply_rect, close_rect) = + settings_button_rects(inner, app.settings.section, show_primary); + if let Some(apply_rect) = apply_rect { + render_action_button( + frame, + apply_rect, + Some("↵"), + primary_label, + Style::default() + .fg(panel_contrast_fg(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + } render_action_button( frame, close_rect, @@ -157,13 +165,47 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R } } -pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) { +pub(crate) fn settings_primary_button_label( + section: crate::app::state::SettingsSection, +) -> &'static str { + match section { + crate::app::state::SettingsSection::Integrations => "install", + _ => "apply", + } +} + +pub(crate) fn settings_show_primary_action(app: &AppState) -> bool { + app.settings.section != crate::app::state::SettingsSection::Integrations + || app + .integration_recommendations + .iter() + .any(crate::integration::IntegrationRecommendation::needs_install) +} + +pub(crate) fn settings_button_rects( + inner: Rect, + section: crate::app::state::SettingsSection, + show_primary: bool, +) -> (Option<Rect>, Rect) { + if !show_primary { + let rects = action_button_row_rects( + inner, + &[ActionButtonSpec { + hint: Some("esc"), + label: "close", + }], + 2, + inner.height.saturating_sub(1), + ); + return (None, rects[0]); + } + let rects = action_button_row_rects( inner, &[ ActionButtonSpec { hint: Some("↵"), - label: "apply", + label: settings_primary_button_label(section), }, ActionButtonSpec { hint: Some("esc"), @@ -173,7 +215,99 @@ pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) { 2, inner.height.saturating_sub(1), ); - (rects[0], rects[1]) + (Some(rects[0]), rects[1]) +} + +fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { + let p = &app.palette; + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas::<4>(area); + + frame.render_widget( + Paragraph::new("agent integrations") + .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)), + rows[0], + ); + frame.render_widget( + Paragraph::new( + "let agents report state directly instead of relying only on process detection", + ) + .style(Style::default().fg(p.overlay1)) + .wrap(ratatui::widgets::Wrap { trim: false }), + rows[1], + ); + + let mut lines = Vec::new(); + for item in &app.integration_recommendations { + let marker = match item.state { + crate::integration::IntegrationStatusKind::Current => "✓", + crate::integration::IntegrationStatusKind::Outdated => "↻", + crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+", + crate::integration::IntegrationStatusKind::NotInstalled => "–", + }; + let marker_style = match item.state { + crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green), + crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow), + crate::integration::IntegrationStatusKind::NotInstalled if item.available => { + Style::default().fg(p.accent) + } + crate::integration::IntegrationStatusKind::NotInstalled => { + Style::default().fg(p.overlay0) + } + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {marker} "), marker_style), + Span::styled( + format!("{:<9}", item.label), + Style::default().fg(p.subtext0), + ), + Span::styled(item.status_label(), Style::default().fg(p.overlay1)), + ])); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + " no integration targets available", + Style::default().fg(p.overlay1), + ))); + } + + if !app.integration_install_messages.is_empty() { + lines.push(Line::from("")); + for message in &app.integration_install_messages { + lines.push(Line::from(Span::styled( + format!(" {message}"), + Style::default().fg(p.overlay1), + ))); + } + } else { + lines.push(Line::from("")); + let found_any = app.integration_recommendations.iter().any(|item| { + item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled + }); + let hint = if app + .integration_recommendations + .iter() + .any(crate::integration::IntegrationRecommendation::needs_install) + { + " press install to add available or outdated integrations" + } else if found_any { + " all detected integrations are installed" + } else { + " no supported agent CLIs found on PATH" + }; + lines.push(Line::from(Span::styled( + hint, + Style::default().fg(p.overlay1), + ))); + } + + frame.render_widget(Paragraph::new(lines), rows[3]); } fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { |
