aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 23:58:17 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 00:00:16 +0300
commitfb93e3747544afc3288d7b5e831d5ca3dd5c3768 (patch)
tree95ed3208395ebe68dc49fbd7d8edb7e16597ba49 /src
parent2bebac461b1d5d6a1ec42d70c2338cf27eee8c56 (diff)
feat: surface agent integrations in onboarding
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs11
-rw-r--r--src/app/input/settings.rs95
-rw-r--r--src/app/mod.rs48
-rw-r--r--src/app/state.rs16
-rw-r--r--src/integration/mod.rs138
-rw-r--r--src/ui.rs2
-rw-r--r--src/ui/onboarding.rs6
-rw-r--r--src/ui/settings.rs162
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();
diff --git a/src/ui.rs b/src/ui.rs
index a08956bb..ed52660e 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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) {