aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input/settings.rs8
-rw-r--r--src/app/mod.rs14
-rw-r--r--src/integration/mod.rs184
-rw-r--r--src/ui.rs5
-rw-r--r--src/ui/settings.rs93
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);
diff --git a/src/ui.rs b/src/ui.rs
index bdae81c9..9c6d24d8 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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) {