aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs70
-rw-r--r--src/app/agents.rs1
-rw-r--r--src/app/api.rs344
-rw-r--r--src/app/api/agents.rs63
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/input/settings.rs6
-rw-r--r--src/app/mod.rs20
-rw-r--r--src/app/runtime.rs20
-rw-r--r--src/app/state.rs11
9 files changed, 525 insertions, 12 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 249d0e64..b218e53c 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2312,6 +2312,36 @@ impl AppState {
}
Vec::new()
}
+ AppEvent::AgentDetectionManifestsUpdated { updated, status } => {
+ self.agent_manifest_update_status = status;
+ self.refresh_agent_manifest_summaries();
+ if !updated.is_empty()
+ && matches!(
+ self.toast_config.delivery,
+ crate::config::ToastDelivery::Herdr
+ )
+ {
+ let agent_list = updated
+ .iter()
+ .map(|item| {
+ format!(
+ "{} {}",
+ crate::detect::agent_label(item.agent),
+ item.version
+ )
+ })
+ .collect::<Vec<_>>()
+ .join(", ");
+ self.toast = Some(ToastNotification {
+ kind: ToastKind::UpdateInstalled,
+ title: "Agent detection rules updated".to_string(),
+ context: agent_list,
+ position: None,
+ target: None,
+ });
+ }
+ Vec::new()
+ }
AppEvent::StateChanged {
pane_id,
agent,
@@ -2520,14 +2550,15 @@ impl AppState {
) -> Option<PaneStateUpdate> {
let observed_at = std::time::Instant::now();
self.update_terminal_state(pane_id, |terminal| {
- let agent = terminal
- .effective_known_agent()
- .or(terminal.detected_agent)?;
+ let agent = terminal.effective_known_agent().or(terminal.detected_agent);
+ if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() {
+ return None;
+ }
Some(terminal.set_detected_state_with_screen_signals_at(
- Some(agent),
+ agent,
AgentState::Idle,
false,
- false,
+ true,
false,
true,
observed_at,
@@ -4684,6 +4715,35 @@ mod tests {
}
#[test]
+ fn agent_detection_manifest_update_event_updates_status_and_toast() {
+ let mut state = AppState::test_new();
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let status = crate::detect::manifest_update::ManifestUpdateStatus {
+ last_result: Some("checked".to_string()),
+ ..Default::default()
+ };
+
+ let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated {
+ updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
+ agent: Agent::Codex,
+ version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
+ .unwrap(),
+ }],
+ status,
+ });
+
+ assert!(updates.is_empty());
+ assert_eq!(
+ state.agent_manifest_update_status.last_result.as_deref(),
+ Some("checked")
+ );
+ let toast = state.toast.as_ref().expect("manifest update toast");
+ assert_eq!(toast.kind, ToastKind::UpdateInstalled);
+ assert_eq!(toast.title, "Agent detection rules updated");
+ assert_eq!(toast.context, "codex 2026.06.10.1");
+ }
+
+ #[test]
fn toggle_zoom_works() {
let mut state = app_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 6d463b28..ea828eae 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -415,6 +415,7 @@ impl App {
title: pane.title,
display_agent: pane.display_agent,
agent_status: pane.agent_status,
+ screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(),
custom_status: pane.custom_status,
state_labels: pane.state_labels,
agent_session: pane.agent_session,
diff --git a/src/app/api.rs b/src/app/api.rs
index 76d9c537..ea4ea378 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -69,6 +69,7 @@ impl App {
if let AppEvent::PaneDied { pane_id } = &ev {
let previous_toast = self.state.toast.clone();
if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) {
+ self.sync_full_lifecycle_authority_detection_pauses();
self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
self.emit_pane_state_update(&update);
self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update));
@@ -123,17 +124,18 @@ impl App {
} else {
None
};
+ let manifest_update_agents =
+ if let AppEvent::AgentDetectionManifestsUpdated { updated, .. } = &ev {
+ Some(updated.iter().map(|item| item.agent).collect::<Vec<_>>())
+ } else {
+ None
+ };
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
- if terminal_cwd_reported {
- self.mark_git_status_refresh_due(Instant::now());
- }
- for update in &pane_updates {
- self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
- self.emit_pane_state_update(update);
+ if let Some(agents) = manifest_update_agents {
+ self.reset_agent_detection_for_agents(&agents);
}
- self.sync_agent_metadata_deadline();
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
@@ -147,6 +149,15 @@ impl App {
}
}
}
+ self.sync_full_lifecycle_authority_detection_pauses();
+ if terminal_cwd_reported {
+ self.mark_git_status_refresh_due(Instant::now());
+ }
+ for update in &pane_updates {
+ self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
+ self.emit_pane_state_update(update);
+ }
+ self.sync_agent_metadata_deadline();
if let Some(overlay) = overlay_state {
self.restore_overlay_after_exit(overlay);
}
@@ -175,6 +186,29 @@ impl App {
self.shutdown_detached_terminal_runtimes();
}
+ fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) {
+ if agents.is_empty() {
+ return;
+ }
+ for (terminal_id, terminal) in &self.state.terminals {
+ let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
+ continue;
+ };
+ if !agents.contains(&agent) {
+ continue;
+ }
+ if let Some(runtime) = self.terminal_runtimes.get(terminal_id) {
+ runtime.reset_agent_detection();
+ }
+ }
+ }
+
+ fn reset_all_agent_detection_runtimes(&self) {
+ for runtime in self.terminal_runtimes.values() {
+ runtime.reset_agent_detection();
+ }
+ }
+
pub(crate) fn refresh_new_herdr_toast_context_for_update(
&mut self,
update: &crate::app::actions::PaneStateUpdate,
@@ -218,6 +252,26 @@ impl App {
}
}
+ fn sync_full_lifecycle_authority_detection_pauses(&self) {
+ for workspace in &self.state.workspaces {
+ for tab in &workspace.tabs {
+ for pane in tab.panes.values() {
+ let Some(terminal) = self.state.terminals.get(&pane.attached_terminal_id)
+ else {
+ continue;
+ };
+ let Some(runtime) = self.terminal_runtimes.get(&pane.attached_terminal_id)
+ else {
+ continue;
+ };
+ runtime.set_full_lifecycle_authority_active(
+ terminal.full_lifecycle_hook_authority_active(),
+ );
+ }
+ }
+ }
+ }
+
pub(crate) fn show_clipboard_feedback(&mut self) {
if !self.state.toast_config.clipboard.enabled {
self.state.copy_feedback = None;
@@ -624,6 +678,39 @@ impl App {
},
}
}
+ Method::ServerAgentManifests(_) => {
+ self.state.refresh_agent_manifest_summaries();
+ let update_status = crate::detect::manifest_update::load_status();
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentManifestStatus {
+ last_check_unix: update_status.last_check_unix,
+ last_result: update_status.last_result.clone(),
+ manifests: self
+ .state
+ .agent_manifest_summaries
+ .clone()
+ .into_iter()
+ .map(|summary| agent_manifest_info(summary, &update_status))
+ .collect(),
+ },
+ }
+ }
+ Method::ServerReloadAgentManifests(_) => {
+ let summaries = crate::detect::manifest::reload_manifests();
+ self.state.agent_manifest_summaries = summaries.clone();
+ let update_status = crate::detect::manifest_update::load_status();
+ self.reset_all_agent_detection_runtimes();
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentManifestReload {
+ manifests: summaries
+ .into_iter()
+ .map(|summary| agent_manifest_info(summary, &update_status))
+ .collect(),
+ },
+ }
+ }
Method::NotificationShow(params) => {
return self.handle_notification_show(request.id, params);
}
@@ -661,6 +748,7 @@ impl App {
Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
+ Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target),
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
@@ -838,6 +926,25 @@ fn sanitized_notification_text(value: &str, max_chars: usize) -> Option<String>
(!sanitized.is_empty()).then_some(sanitized)
}
+fn agent_manifest_info(
+ summary: crate::detect::manifest::AgentManifestSummary,
+ update_status: &crate::detect::manifest_update::ManifestUpdateStatus,
+) -> crate::api::schema::AgentManifestInfo {
+ let remote = update_status.agent_status(summary.agent);
+ crate::api::schema::AgentManifestInfo {
+ agent: crate::detect::agent_label(summary.agent).to_string(),
+ source: summary.active_source.label(),
+ source_kind: summary.active_source.kind().to_string(),
+ active_version: summary.active_version,
+ cached_remote_version: summary.cached_remote_version,
+ local_override_shadowing_remote: summary.local_override_shadowing_remote,
+ remote_update_result: remote.as_ref().map(|status| status.last_result.clone()),
+ remote_update_error: remote.as_ref().and_then(|status| status.last_error.clone()),
+ remote_last_checked_unix: remote.and_then(|status| status.last_checked_unix),
+ warning: summary.warning,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -853,6 +960,229 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
+ #[tokio::test]
+ async fn manifest_update_event_resets_matching_agent_detection_runtime() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reset")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Codex);
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated {
+ updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
+ agent: Agent::Codex,
+ version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1")
+ .unwrap(),
+ }],
+ status: crate::detect::manifest_update::ManifestUpdateStatus::default(),
+ });
+
+ tokio::time::timeout(
+ std::time::Duration::from_millis(50),
+ reset_notify.notified(),
+ )
+ .await
+ .expect("matching agent detection runtime should be reset");
+ }
+
+ #[tokio::test]
+ async fn server_reload_agent_manifests_resets_detection_runtimes() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reload")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "reload_manifests".into(),
+ method: crate::api::schema::Method::ServerReloadAgentManifests(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["result"]["type"], "agent_manifest_reload");
+ assert!(!response["result"]["manifests"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+
+ tokio::time::timeout(
+ std::time::Duration::from_millis(50),
+ reset_notify.notified(),
+ )
+ .await
+ .expect("manual manifest reload should reset detection runtimes");
+ }
+
+ #[tokio::test]
+ async fn server_agent_manifests_reports_status_without_resetting_runtimes() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-status")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ let reset_notify = runtime.agent_detection_reset_notify_for_test();
+ app.terminal_runtimes.insert(terminal_id, runtime);
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "manifest_status".into(),
+ method: crate::api::schema::Method::ServerAgentManifests(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["result"]["type"], "agent_manifest_status");
+ assert!(!response["result"]["manifests"]
+ .as_array()
+ .unwrap()
+ .is_empty());
+ assert!(
+ tokio::time::timeout(
+ std::time::Duration::from_millis(10),
+ reset_notify.notified(),
+ )
+ .await
+ .is_err(),
+ "status request should not reset detection runtimes"
+ );
+ }
+
+ #[tokio::test]
+ async fn agent_explain_evaluates_with_server_manifest_cache() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Codex);
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ 80,
+ 24,
+ b"press enter to confirm or esc to cancel",
+ );
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "agent_explain".into(),
+ method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
+ target,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "agent_explain");
+ assert_eq!(response["result"]["explain"]["state"], "blocked");
+ assert_eq!(
+ response["result"]["explain"]["matched_rule"]["id"],
+ "live_strong_blocker"
+ );
+ }
+
+ #[tokio::test]
+ async fn agent_explain_reports_hook_only_full_lifecycle_authority() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![crate::workspace::Workspace::test_new("agent-explain-omp")];
+ app.state.ensure_test_terminals();
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_hook_authority(
+ "herdr:omp".to_string(),
+ "omp".to_string(),
+ AgentState::Working,
+ None,
+ Some(1),
+ );
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ let target = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "agent_explain_omp".into(),
+ method: crate::api::schema::Method::AgentExplain(crate::api::schema::AgentTarget {
+ target,
+ }),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+
+ assert_eq!(response["result"]["type"], "agent_explain");
+ assert_eq!(response["result"]["explain"]["agent"], "omp");
+ assert_eq!(response["result"]["explain"]["state"], "working");
+ assert_eq!(
+ response["result"]["explain"]["screen_detection_skip_reason"],
+ "full_lifecycle_hook_authority"
+ );
+ assert_eq!(
+ response["result"]["explain"]["matched_rule"],
+ serde_json::Value::Null
+ );
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index 4077b38c..f89be0ce 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -73,11 +73,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
};
@@ -102,6 +104,67 @@ impl App {
)
}
+ pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String {
+ let resolved = match self.resolve_terminal_target(&target.target) {
+ Ok(resolved) => resolved,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+ let Some((pane, _workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
+ else {
+ return agent_not_found(id, &target.target);
+ };
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(resolved.ws_idx)
+ .and_then(|workspace| workspace.terminal_id(resolved.pane_id))
+ else {
+ return agent_not_found(id, &target.target);
+ };
+ let Some(terminal) = self.state.terminals.get(terminal_id) else {
+ return agent_not_found(id, &target.target);
+ };
+ if terminal.full_lifecycle_hook_authority_active() {
+ let explain = serde_json::json!({
+ "agent": terminal.effective_agent_label().unwrap_or("unknown"),
+ "state": crate::detect::manifest::agent_state_label(terminal.state),
+ "manifest_source": null,
+ "manifest_version": null,
+ "cached_remote_version": null,
+ "local_override_shadowing_remote": false,
+ "remote_update_status": null,
+ "remote_update_error": null,
+ "matched_rule": null,
+ "visible_idle": false,
+ "visible_blocker": false,
+ "visible_working": false,
+ "screen_detection_skipped": true,
+ "screen_detection_skip_reason": "full_lifecycle_hook_authority",
+ "skip_state_update": false,
+ "skipped_update_reason": null,
+ "fallback_reason": null,
+ "warning": null,
+ "evaluated_rules": [],
+ });
+ return encode_success(id, ResponseResult::AgentExplain { explain });
+ }
+ let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else {
+ return encode_error(
+ id,
+ "agent_explain_unavailable",
+ format!(
+ "agent target {} does not have a detected agent label",
+ target.target
+ ),
+ );
+ };
+
+ let explain = crate::detect::manifest::explain(agent, &pane.detection_text());
+ let value = crate::detect::manifest::explain_to_json_value(&explain);
+
+ encode_success(id, ResponseResult::AgentExplain { explain: value })
+ }
+
pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String {
let resolved = match self.resolve_terminal_target(&params.target) {
Ok(resolved) => resolved,
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index d824d26b..5afac850 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -616,11 +616,13 @@ impl App {
ReadSource::Visible => pane.visible_text(),
ReadSource::Recent => pane.recent_text(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
ReadFormat::Ansi => match params.source {
ReadSource::Visible => pane.visible_ansi(),
ReadSource::Recent => pane.recent_ansi(requested_lines),
ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ ReadSource::Detection => pane.detection_text(),
},
};
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 66ec3226..0e7c0d81 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -603,6 +603,12 @@ mod tests {
KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
);
assert_eq!(state.settings.section, SettingsSection::Integrations);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::BackTab, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.section, SettingsSection::PaneLabels);
}
#[test]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 35ab92b1..c0af6754 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -107,6 +107,7 @@ pub struct App {
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
+ pub(crate) next_agent_manifest_update_check: Option<Instant>,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
@@ -398,6 +399,8 @@ impl App {
state::Mode::Navigate
};
+ let agent_manifest_summaries = crate::detect::manifest::reload_manifests();
+
let mut state = AppState {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
@@ -535,6 +538,8 @@ impl App {
original_theme: None,
},
integration_recommendations: crate::integration::integration_recommendations(),
+ agent_manifest_summaries,
+ agent_manifest_update_status: crate::detect::manifest_update::load_status(),
integration_install_messages: Vec::new(),
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
@@ -557,6 +562,10 @@ impl App {
if auto_updates_enabled(no_session) {
let update_tx = event_tx.clone();
std::thread::spawn(move || crate::update::auto_update(update_tx));
+ let manifest_update_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ crate::detect::manifest_update::auto_update(manifest_update_tx)
+ });
}
let last_focus = state.active.and_then(|idx| {
@@ -585,6 +594,8 @@ impl App {
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
+ next_agent_manifest_update_check: auto_updates_enabled(no_session)
+ .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
agent_metadata_deadline: None,
pending_agent_resume_deadline: None,
session_save_deadline: None,
@@ -635,6 +646,15 @@ impl App {
let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces);
app.no_session = false;
+ if auto_updates_enabled(app.no_session) {
+ let now = Instant::now();
+ app.next_auto_update_check = app
+ .state
+ .update_available
+ .is_none()
+ .then_some(now + AUTO_UPDATE_CHECK_INTERVAL);
+ app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL);
+ }
app.state.detach_exits = false;
app.state.pane_id_aliases = pane_id_aliases;
app.state.workspaces = workspaces;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index ca780360..cb2e5712 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -257,6 +257,13 @@ impl App {
}
if self
+ .next_agent_manifest_update_check
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.run_agent_manifest_update_check();
+ }
+
+ if self
.session_save_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -448,6 +455,18 @@ impl App {
std::thread::spawn(move || crate::update::auto_update(update_tx));
}
+ pub(crate) fn run_agent_manifest_update_check(&mut self) {
+ if !auto_updates_enabled(self.no_session) {
+ self.next_agent_manifest_update_check = None;
+ return;
+ }
+
+ self.next_agent_manifest_update_check = Some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL);
+
+ let manifest_update_tx = self.event_tx.clone();
+ std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx));
+ }
+
pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) {
let Some(deadline) = self.git_refresh_deadline() else {
return;
@@ -531,6 +550,7 @@ impl App {
.then(|| self.git_refresh_deadline())
.flatten(),
self.next_auto_update_check,
+ self.next_agent_manifest_update_check,
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.session_save_deadline,
diff --git a/src/app/state.rs b/src/app/state.rs
index 01f3d9c8..f37ccb22 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1377,6 +1377,10 @@ pub struct AppState {
pub settings: SettingsState,
/// Cached integration recommendations for onboarding/settings UI.
pub integration_recommendations: Vec<crate::integration::IntegrationRecommendation>,
+ /// Cached detection manifest source/version summaries for runtime/API status.
+ pub agent_manifest_summaries: Vec<crate::detect::manifest::AgentManifestSummary>,
+ /// Cached remote detection manifest update diagnostics for runtime/API status.
+ pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus,
/// Result messages from the latest integration install action.
pub integration_install_messages: Vec<String>,
/// Highlight state for the bottom-right global launcher menu.
@@ -1433,6 +1437,10 @@ impl AppState {
.any(|item| item.state == crate::integration::IntegrationStatusKind::Outdated)
}
+ pub(crate) fn refresh_agent_manifest_summaries(&mut self) {
+ self.agent_manifest_summaries = crate::detect::manifest::manifest_summaries();
+ }
+
pub(crate) fn global_menu_attention_badge_visible(&self) -> bool {
self.update_available.is_some() || self.integration_updates_available()
}
@@ -1700,6 +1708,9 @@ impl AppState {
original_theme: None,
},
integration_recommendations: Vec::new(),
+ agent_manifest_summaries: Vec::new(),
+ agent_manifest_update_status:
+ crate::detect::manifest_update::ManifestUpdateStatus::default(),
integration_install_messages: Vec::new(),
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),