diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 20:12:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-10 20:12:05 +0300 |
| commit | 42ec7221310a4193f2a3717e2ea557c604dd07ae (patch) | |
| tree | 614688c2f3a17f7ae32077f4dd5692d00f90a943 | |
| parent | 09c0dabfb51d8bc48b450d84958d19b1b647d77a (diff) | |
feat: gate kimi integration install behind minimum agent version
| -rw-r--r-- | src/app/input/settings.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 14 | ||||
| -rw-r--r-- | src/integration/mod.rs | 184 | ||||
| -rw-r--r-- | src/ui.rs | 5 | ||||
| -rw-r--r-- | src/ui/settings.rs | 93 |
5 files changed, 264 insertions, 40 deletions
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 0e7c0d81..cc4f6d58 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -297,6 +297,7 @@ pub(crate) fn open_settings(state: &mut AppState) { } pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { + state.integration_install_messages.clear(); state.settings.original_palette = Some(state.palette.clone()); state.settings.original_theme = Some(state.theme_name.clone()); state.settings.section = section; @@ -313,7 +314,12 @@ pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { impl AppState { fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() + crate::ui::centered_popup_rect( + self.screen_rect(), + crate::ui::SETTINGS_POPUP_WIDTH, + crate::ui::settings_popup_height(self), + ) + .unwrap_or_default() } fn settings_inner_rect(&self) -> Rect { diff --git a/src/app/mod.rs b/src/app/mod.rs index c0af6754..88a489e3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1089,10 +1089,16 @@ impl App { 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}")), + Ok(messages) => { + self.state + .integration_install_messages + .push(format!("installed {label}")); + self.state.integration_install_messages.extend( + messages.into_iter().filter(|message| { + message.starts_with(crate::integration::INSTALL_WARNING_PREFIX) + }), + ); + } Err(err) => self .state .integration_install_messages diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b063f23b..b5ace389 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -365,9 +365,101 @@ pub(crate) fn apply_pane_env(cmd: &mut CommandBuilder, pane_id: PaneId) { cmd.env(HERDR_PANE_ID_ENV_VAR, format!("p_{}", pane_id.raw())); } +pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; + +struct AgentVersionRequirement { + label: &'static str, + binary: &'static str, + args: &'static [&'static str], + min_version: &'static str, +} + +fn agent_version_requirement( + target: crate::api::schema::IntegrationTarget, +) -> Option<AgentVersionRequirement> { + match target { + crate::api::schema::IntegrationTarget::Kimi => Some(AgentVersionRequirement { + label: "kimi code", + binary: "kimi", + args: &["--version"], + min_version: KIMI_MIN_VERSION, + }), + _ => None, + } +} + +fn extract_version_triple(text: &str) -> Option<(u64, u64, u64)> { + text.split_whitespace().find_map(|token| { + let token = token.trim_start_matches('v'); + let mut parts = token.splitn(3, '.'); + let major: u64 = parts.next()?.parse().ok()?; + let minor: u64 = parts.next()?.parse().ok()?; + let patch: u64 = parts + .next() + .map(|rest| { + rest.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::<String>() + }) + .and_then(|digits| digits.parse().ok()) + .unwrap_or(0); + Some((major, minor, patch)) + }) +} + +/// Returns `Ok(None)` when the installed agent satisfies the requirement, +/// `Ok(Some(warning))` when the version cannot be determined (install +/// proceeds), and `Err` when the installed agent is too old. +fn enforce_agent_version(requirement: &AgentVersionRequirement) -> io::Result<Option<String>> { + let probe = format!("{} {}", requirement.binary, requirement.args.join(" ")); + let output = match std::process::Command::new(requirement.binary) + .args(requirement.args) + .output() + { + Ok(output) if output.status.success() => output, + _ => { + return Ok(Some(format!( + "{INSTALL_WARNING_PREFIX} could not run `{probe}` to verify the installed version; hooks require {} {} or newer", + requirement.label, requirement.min_version + ))); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(found) = extract_version_triple(&stdout) else { + return Ok(Some(format!( + "{INSTALL_WARNING_PREFIX} could not parse the {} version from `{probe}` output; hooks require {} {} or newer", + requirement.label, requirement.label, requirement.min_version + ))); + }; + let required = extract_version_triple(requirement.min_version) + .expect("static min version must be a valid version triple"); + + if found < required { + return Err(io::Error::other(format!( + "{label} {}.{}.{} is too old: herdr hooks require {label} {min} or newer. upgrade {label}, then re-run install", + found.0, + found.1, + found.2, + label = requirement.label, + min = requirement.min_version + ))); + } + Ok(None) +} + pub(crate) fn install_target( target: crate::api::schema::IntegrationTarget, ) -> io::Result<Vec<String>> { + let result = install_target_inner(target); + let outcome = if result.is_ok() { "ok" } else { "error" }; + crate::logging::integration_action("install", integration_target_label(target), outcome); + result +} + +fn install_target_inner( + target: crate::api::schema::IntegrationTarget, +) -> io::Result<Vec<String>> { if !integration_target_supported(target) { return Err(io::Error::other(format!( "{} integration is not supported on Windows", @@ -375,7 +467,12 @@ pub(crate) fn install_target( ))); } - let messages = match target { + let version_warning = match agent_version_requirement(target) { + Some(requirement) => enforce_agent_version(&requirement)?, + None => None, + }; + + let mut messages = match target { crate::api::schema::IntegrationTarget::Pi => { let path = install_pi()?; vec![format!("installed pi integration to {}", path.display())] @@ -521,7 +618,10 @@ pub(crate) fn install_target( } }; - crate::logging::integration_action("install", integration_target_label(target), "ok"); + if let Some(warning) = version_warning { + messages.push(warning); + } + Ok(messages) } @@ -3204,6 +3304,86 @@ pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> { mod tests { use super::*; + #[test] + fn extract_version_triple_parses_common_outputs() { + assert_eq!(extract_version_triple("0.14.0"), Some((0, 14, 0))); + assert_eq!(extract_version_triple("v1.2.3"), Some((1, 2, 3))); + assert_eq!( + extract_version_triple("kimi-code 0.14.0 (linux/x64)"), + Some((0, 14, 0)) + ); + assert_eq!(extract_version_triple("0.14"), Some((0, 14, 0))); + assert_eq!(extract_version_triple("0.14.1-beta.2"), Some((0, 14, 1))); + assert_eq!(extract_version_triple("no version here"), None); + assert_eq!(extract_version_triple(""), None); + } + + #[test] + fn extract_version_triple_orders_versions() { + let old = extract_version_triple("0.12.1").unwrap(); + let min = extract_version_triple(KIMI_MIN_VERSION).unwrap(); + let new = extract_version_triple("0.15.0").unwrap(); + assert!(old < min); + assert!(min <= min); + assert!(min < new); + } + + #[test] + fn agent_version_requirement_only_set_for_kimi() { + let requirement = agent_version_requirement(crate::api::schema::IntegrationTarget::Kimi) + .expect("kimi must have a version requirement"); + assert_eq!(requirement.binary, "kimi"); + assert_eq!(requirement.min_version, KIMI_MIN_VERSION); + assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Claude).is_none()); + assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Codex).is_none()); + } + + #[test] + fn enforce_agent_version_warns_when_binary_missing() { + let requirement = AgentVersionRequirement { + label: "kimi code", + binary: "herdr-test-binary-that-does-not-exist", + args: &["--version"], + min_version: "0.14.0", + }; + let warning = enforce_agent_version(&requirement) + .expect("missing binary must not fail the install") + .expect("missing binary must produce a warning"); + assert!(warning.contains("could not run")); + assert!(warning.contains("0.14.0")); + } + + #[cfg(unix)] + #[test] + fn enforce_agent_version_rejects_old_version() { + let requirement = AgentVersionRequirement { + label: "kimi code", + binary: "echo", + args: &["0.12.1"], + min_version: "0.14.0", + }; + let err = enforce_agent_version(&requirement) + .expect_err("old version must fail the install"); + let message = err.to_string(); + assert!(message.contains("0.12.1")); + assert!(message.contains("0.14.0")); + assert!(message.contains("upgrade")); + } + + #[cfg(unix)] + #[test] + fn enforce_agent_version_accepts_current_version() { + let requirement = AgentVersionRequirement { + label: "kimi code", + binary: "echo", + args: &["0.14.0"], + min_version: "0.14.0", + }; + let result = enforce_agent_version(&requirement) + .expect("matching version must not fail the install"); + assert!(result.is_none(), "matching version must not warn"); + } + fn clear_integration_path_env() { std::env::remove_var(PI_CODING_AGENT_DIR_ENV_VAR); std::env::remove_var(CLAUDE_CONFIG_DIR_ENV_VAR); @@ -63,7 +63,10 @@ pub(crate) use self::{ open_existing_worktree_visible_start, remove_worktree_button_rects, remove_worktree_popup_rect, rename_button_rects, }, - settings::{settings_button_rects, settings_show_primary_action}, + settings::{ + settings_button_rects, settings_popup_height, settings_show_primary_action, + SETTINGS_POPUP_WIDTH, + }, 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/settings.rs b/src/ui/settings.rs index d17ba4ed..8975d6f3 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -18,11 +18,26 @@ use crate::{ config::ToastDelivery, }; +pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76; +pub(crate) const SETTINGS_POPUP_BASE_HEIGHT: u16 = 22; + +pub(crate) fn settings_popup_height(app: &AppState) -> u16 { + if app.settings.section != crate::app::state::SettingsSection::Integrations { + return SETTINGS_POPUP_BASE_HEIGHT; + } + let list_rows = app.integration_recommendations.len().max(1) as u16; + let footer_rows = integrations_footer_height(app, SETTINGS_POPUP_WIDTH - 2); + // borders 2 + header 3 + stack gaps 2 + modal footer 2 + // + section title 1 + description 2 + spacers 2 + (14 + list_rows + footer_rows).max(SETTINGS_POPUP_BASE_HEIGHT) +} + pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { use crate::app::state::SettingsSection; let p = &app.palette; - let Some(popup) = centered_popup_rect(area, 76, 22) else { + let Some(popup) = centered_popup_rect(area, SETTINGS_POPUP_WIDTH, settings_popup_height(app)) + else { return; }; @@ -239,15 +254,58 @@ pub(crate) fn settings_button_rects( (Some(rects[0]), rects[1]) } +fn integrations_footer_paragraph(app: &AppState) -> Paragraph<'static> { + let p = &app.palette; + let mut footer_lines = Vec::new(); + if !app.integration_install_messages.is_empty() { + for message in &app.integration_install_messages { + footer_lines.push(Line::from(Span::styled( + format!(" {message}"), + Style::default().fg(p.overlay1), + ))); + } + } else { + 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" + }; + footer_lines.push(Line::from(Span::styled( + hint.to_string(), + Style::default().fg(p.overlay1), + ))); + } + Paragraph::new(footer_lines).wrap(ratatui::widgets::Wrap { trim: false }) +} + +fn integrations_footer_height(app: &AppState, width: u16) -> u16 { + (integrations_footer_paragraph(app).line_count(width) as u16).min(6) +} + fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; + + let footer = integrations_footer_paragraph(app); + let footer_height = integrations_footer_height(app, area.width); + let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(footer_height), ]) - .areas::<4>(area); + .areas::<6>(area); frame.render_widget( Paragraph::new("agent integrations") @@ -298,37 +356,8 @@ fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { ))); } - 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]); + frame.render_widget(footer, rows[5]); } fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { |
