Files
herdr/src/app/api.rs
T
2026-06-13 01:59:24 +03:00

1489 lines
58 KiB
Rust

use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
mod agents;
mod integrations;
mod panes;
mod responses;
mod tabs;
mod workspaces;
mod worktrees;
use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind};
use crate::events::AppEvent;
const API_NOTIFICATION_RATE_LIMIT: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimeExitAction {
RespawnShell,
ClosePane,
}
impl App {
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
if let AppEvent::ClipboardWrite { content } = ev {
#[cfg(not(test))]
crate::selection::write_osc52_bytes(&content);
#[cfg(test)]
let _ = content;
self.show_clipboard_feedback();
return;
}
if let AppEvent::GitStatusRefreshed {
results,
cache_updates,
} = ev
{
self.git_refresh_in_flight = false;
for (key, entry) in cache_updates {
self.git_status_cache.insert(key, entry);
}
if self.git_refresh_due_after_in_flight {
self.mark_git_status_refresh_due(Instant::now());
self.git_refresh_due_after_in_flight = false;
} else {
self.last_git_remote_status_refresh = Instant::now();
}
if self
.state
.apply_workspace_git_statuses(&self.terminal_runtimes, results)
{
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
}
return;
}
if let AppEvent::WorktreeAddFinished(result) = ev {
self.handle_worktree_add_finished(result);
return;
}
if let AppEvent::WorktreeRemoveFinished(result) = ev {
self.handle_worktree_remove_finished(result);
return;
}
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));
}
if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell
&& self.respawn_shell_for_launch_pane(*pane_id)
{
self.overlay_panes.remove(pane_id);
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
return;
}
}
let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev {
self.overlay_panes.remove(pane_id)
} else {
None
};
if let AppEvent::PaneDied { pane_id } = &ev {
if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneExited,
data: crate::api::schema::EventData::PaneExited {
pane_id: public_pane_id,
workspace_id: self.public_workspace_id(ws_idx),
},
});
}
}
}
let released_agent = if let AppEvent::HookAgentReleased {
pane_id,
known_agent,
..
} = &ev
{
known_agent.map(|agent| (*pane_id, agent))
} else {
None
};
let update_ready = if let AppEvent::UpdateReady {
version,
install_command,
} = &ev
{
Some((version.clone(), install_command.clone()))
} 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 let Some(agents) = manifest_update_agents {
self.reset_agent_detection_for_agents(&agents);
}
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) {
if let Some(runtime) = self.state.runtime_for_pane_in_workspace(
&self.terminal_runtimes,
ws_idx,
pane_id,
) {
runtime.begin_graceful_release(agent);
}
}
}
}
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);
}
if self.local_terminal_notifications
&& matches!(
self.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System
)
{
let notify = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification,
crate::config::ToastDelivery::System => crate::platform::show_desktop_notification,
_ => unreachable!("toast delivery was checked above"),
};
if let Some((version, install_command)) = update_ready {
let instruction = crate::update::update_install_instruction(&install_command);
let _ = notify(&format!("v{version} available"), Some(&instruction));
} else if self.state.toast_config.delay_seconds == 0 {
self.emit_terminal_or_system_agent_notifications(&pane_updates);
}
}
self.sync_toast_deadline(previous_toast);
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,
previous_toast: &Option<crate::app::state::ToastNotification>,
) {
if !matches!(
self.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
) || self.state.toast == *previous_toast
{
return;
}
let Some(target) = self
.state
.toast
.as_ref()
.and_then(|toast| toast.target.as_ref())
else {
return;
};
if target.pane_id != update.pane_id {
return;
}
let Some(ws) = self.state.workspaces.get(update.ws_idx) else {
return;
};
if ws.id != target.workspace_id {
return;
}
let workspace_label = ws.display_name_from(&self.state.terminals, &self.terminal_runtimes);
let context = crate::app::actions::notification_context(
ws,
&workspace_label,
update.ws_idx,
update.pane_id,
);
if let Some(toast) = self.state.toast.as_mut() {
toast.context = context;
}
}
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;
self.copy_feedback_deadline = None;
return;
}
self.state.copy_feedback = Some(crate::app::state::CopyFeedback {
message: "copied to clipboard".to_string(),
});
self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION);
}
fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
for temp_file in &overlay.temp_files {
let _ = std::fs::remove_file(temp_file);
}
let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
return;
};
if overlay.tab_idx >= ws.tabs.len() {
return;
}
ws.active_tab = overlay.tab_idx;
let tab = &mut ws.tabs[overlay.tab_idx];
if tab.panes.contains_key(&overlay.previous_focus) {
tab.layout.focus_pane(overlay.previous_focus);
}
tab.zoomed = overlay.previous_zoomed;
if self.state.active == Some(overlay.ws_idx) {
self.state.mode = Mode::Terminal;
}
}
fn runtime_exit_action(&self, pane_id: crate::layout::PaneId) -> RuntimeExitAction {
let Some((_, pane_state)) = self.find_pane(pane_id) else {
return RuntimeExitAction::ClosePane;
};
let Some(terminal) = self.state.terminals.get(&pane_state.attached_terminal_id) else {
return RuntimeExitAction::ClosePane;
};
if terminal.respawn_shell_on_exit {
RuntimeExitAction::RespawnShell
} else {
RuntimeExitAction::ClosePane
}
}
fn respawn_shell_for_launch_pane(&mut self, pane_id: crate::layout::PaneId) -> bool {
let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else {
return false;
};
let public_pane_id = self.public_pane_id(ws_idx, pane_id);
let terminal_id = pane_state.attached_terminal_id.clone();
let Some(terminal) = self.state.terminals.get(&terminal_id) else {
return false;
};
let cwd = terminal.cwd.clone();
let (rows, cols) = self
.terminal_runtimes
.get(&terminal_id)
.map(|runtime| runtime.current_size())
.unwrap_or_else(|| self.state.estimate_pane_size());
let runtime = match crate::terminal::TerminalRuntime::spawn(
pane_id,
rows,
cols,
cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
self.event_tx.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
public_pane_id.as_deref(),
) {
Ok(runtime) => runtime,
Err(err) => {
tracing::warn!(
pane = pane_id.raw(),
terminal = %terminal_id,
err = %err,
"failed to respawn shell after launch command exited"
);
return false;
}
};
self.terminal_runtimes.insert(terminal_id.clone(), runtime);
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
terminal.clear_agent_runtime_identity_after_respawn();
}
self.state.focus_pane_in_workspace(ws_idx, pane_id);
self.schedule_session_save();
true
}
pub(crate) fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
return;
};
let workspace_id = self.public_workspace_id(update.ws_idx);
if update.previous_agent_label != update.agent_label {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneAgentDetected,
data: crate::api::schema::EventData::PaneAgentDetected {
pane_id: pane_id.clone(),
workspace_id: workspace_id.clone(),
agent: update.agent_label.clone(),
},
});
}
let previous_agent_status = pane_agent_status(update.previous_state, update.previous_seen);
let agent_status = self
.state
.workspaces
.get(update.ws_idx)
.and_then(|ws| ws.pane_state(update.pane_id))
.map(|pane| pane_agent_status(update.state, pane.seen))
.unwrap_or_else(|| pane_agent_status(update.state, update.seen));
if previous_agent_status != agent_status
|| update.previous_presentation != update.presentation
{
let presentation = update.presentation.clone();
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneAgentStatusChanged,
data: crate::api::schema::EventData::PaneAgentStatusChanged {
pane_id,
workspace_id,
agent_status,
agent: update.agent_label.clone(),
title: presentation.title,
display_agent: presentation.display_agent,
custom_status: presentation.custom_status,
state_labels: presentation.state_labels,
},
});
}
}
fn emit_terminal_or_system_agent_notifications(
&self,
pane_updates: &[crate::app::actions::PaneStateUpdate],
) {
if !self.local_terminal_notifications
|| self.state.toast_config.delay_seconds != 0
|| !matches!(
self.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System
)
{
return;
}
let notify = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification,
crate::config::ToastDelivery::System => crate::platform::show_desktop_notification,
_ => return,
};
for update in pane_updates {
let is_active_tab = self
.state
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
let suppress_active_tab_notifications =
crate::app::actions::active_tab_suppresses_notifications(
is_active_tab,
self.state.outer_terminal_focus,
);
let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update(
suppress_active_tab_notifications,
update,
) else {
continue;
};
let Some(ws) = self.state.workspaces.get(update.ws_idx) else {
continue;
};
let Some(pane) = ws
.tabs
.iter()
.find_map(|tab| tab.panes.get(&update.pane_id))
else {
continue;
};
let Some(agent_label) = self
.state
.terminals
.get(&pane.attached_terminal_id)
.and_then(|terminal| terminal.effective_agent_label())
else {
continue;
};
let event_text = match kind {
ToastKind::NeedsAttention => "needs attention",
ToastKind::Finished => "finished",
ToastKind::UpdateInstalled => "updated",
};
let workspace_label =
ws.display_name_from(&self.state.terminals, &self.terminal_runtimes);
let _ = notify(
&format!("{} {}", agent_label, event_text),
Some(&crate::app::actions::notification_context(
ws,
&workspace_label,
update.ws_idx,
update.pane_id,
)),
);
}
}
pub(crate) fn sync_toast_deadline(
&mut self,
previous_toast: Option<crate::app::state::ToastNotification>,
) {
if self.state.toast != previous_toast {
self.toast_deadline = self.state.toast.as_ref().map(|toast| {
let duration = match toast.kind {
ToastKind::NeedsAttention => Duration::from_secs(8),
ToastKind::Finished => Duration::from_secs(5),
ToastKind::UpdateInstalled => Duration::from_secs(3),
};
Instant::now() + duration
});
}
}
pub(crate) fn emit_delayed_client_local_agent_notifications(
&self,
deliveries: &[crate::app::state::AgentNotificationDelivery],
) {
if !self.local_terminal_notifications
|| !matches!(
self.state.toast_config.delivery,
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System
)
{
return;
}
let notify = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification,
crate::config::ToastDelivery::System => crate::platform::show_desktop_notification,
_ => unreachable!("toast delivery was checked above"),
};
for delivery in deliveries {
let Some(toast) = &delivery.client_notification else {
continue;
};
let _ = notify(&toast.title, Some(&toast.context));
}
}
pub(crate) fn refresh_agent_notification_delivery_contexts(
&mut self,
deliveries: &mut [crate::app::state::AgentNotificationDelivery],
) {
for delivery in deliveries {
let Some(ws_idx) = self
.state
.workspaces
.iter()
.position(|ws| ws.id == delivery.workspace_id)
else {
continue;
};
let ws = &self.state.workspaces[ws_idx];
let workspace_label =
ws.display_name_from(&self.state.terminals, &self.terminal_runtimes);
let context = crate::app::actions::notification_context(
ws,
&workspace_label,
ws_idx,
delivery.pane_id,
);
if let Some(toast) = delivery.toast.as_mut() {
toast.context = context.clone();
}
if let Some(toast) = delivery.client_notification.as_mut() {
toast.context = context.clone();
}
if let Some(toast) = self.state.toast.as_mut() {
if toast.target.as_ref().is_some_and(|target| {
target.workspace_id == delivery.workspace_id
&& target.pane_id == delivery.pane_id
}) {
toast.context = context;
}
}
}
}
pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) {
self.event_hub.push(event);
}
pub(crate) fn sync_focus_events(&mut self) {
let current_focus = self.state.active.and_then(|idx| {
self.state
.workspaces
.get(idx)
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
if current_focus == self.last_focus {
return;
}
if let Some((ws_idx, pane_id)) = self.last_focus {
self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost);
}
if let Some((ws_idx, pane_id)) = current_focus {
self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained);
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::WorkspaceFocused,
data: crate::api::schema::EventData::WorkspaceFocused {
workspace_id: self.public_workspace_id(ws_idx),
},
});
if let Some(tab_id) =
self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab)
{
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::TabFocused,
data: crate::api::schema::EventData::TabFocused {
tab_id,
workspace_id: self.public_workspace_id(ws_idx),
},
});
}
if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneFocused,
data: crate::api::schema::EventData::PaneFocused {
pane_id: public_pane_id,
workspace_id: self.public_workspace_id(ws_idx),
},
});
}
}
self.last_focus = current_focus;
}
fn send_pane_focus_event(
&self,
ws_idx: usize,
pane_id: crate::layout::PaneId,
event: crate::ghostty::FocusEvent,
) {
let Some(runtime) = self.state.workspaces.get(ws_idx).and_then(|_| {
self.state
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
}) else {
return;
};
runtime.try_send_focus_event(event);
}
pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
self.drain_all_internal_events();
self.handle_api_request_after_internal_events_drained(request)
}
pub(crate) fn handle_api_request_after_internal_events_drained(
&mut self,
request: crate::api::schema::Request,
) -> String {
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse,
};
let response = match request.method {
Method::ServerStop(_) => {
self.state.should_quit = true;
SuccessResponse {
id: request.id,
result: ResponseResult::Ok {},
}
}
Method::ServerLiveHandoff(_) => {
let response = ErrorResponse {
id: request.id,
error: ErrorBody {
code: "unsupported_in_app_mode".into(),
message: "live handoff is only supported by the headless server".into(),
},
};
return serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string());
}
Method::ServerReloadConfig(_) => {
let report = self.reload_config();
SuccessResponse {
id: request.id,
result: ResponseResult::ConfigReload {
status: report.status,
diagnostics: report.diagnostics,
},
}
}
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);
}
Method::WorkspaceList(_) => return self.handle_workspace_list(request.id),
Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target),
Method::WorkspaceCreate(params) => {
return self.handle_workspace_create(request.id, params);
}
Method::WorkspaceFocus(target) => {
return self.handle_workspace_focus(request.id, target)
}
Method::WorkspaceRename(params) => {
return self.handle_workspace_rename(request.id, params);
}
Method::WorkspaceClose(target) => {
return self.handle_workspace_close(request.id, target)
}
Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params),
Method::WorktreeCreate(params) => {
return self.handle_worktree_create(request.id, params);
}
Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params),
Method::WorktreeRemove(params) => {
return self.handle_worktree_remove(request.id, params);
}
Method::TabList(params) => return self.handle_tab_list(request.id, params),
Method::TabGet(target) => return self.handle_tab_get(request.id, target),
Method::TabCreate(params) => return self.handle_tab_create(request.id, params),
Method::TabFocus(target) => return self.handle_tab_focus(request.id, target),
Method::TabRename(params) => return self.handle_tab_rename(request.id, params),
Method::TabClose(target) => return self.handle_tab_close(request.id, target),
Method::AgentList(_) => return self.handle_agent_list(request.id),
Method::AgentGet(target) => return self.handle_agent_get(request.id, target),
Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target),
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),
Method::PaneMove(params) => return self.handle_pane_move(request.id, params),
Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params),
Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),
Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params),
Method::PaneFocusDirection(params) => {
return self.handle_pane_focus_direction(request.id, params);
}
Method::PaneResize(params) => return self.handle_pane_resize(request.id, params),
Method::PaneList(params) => return self.handle_pane_list(request.id, params),
Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
Method::PaneReportAgent(params) => {
return self.handle_pane_report_agent(request.id, params);
}
Method::PaneReportAgentSession(params) => {
return self.handle_pane_report_agent_session(request.id, params);
}
Method::PaneReportMetadata(params) => {
return self.handle_pane_report_metadata(request.id, params);
}
Method::PaneClearAgentAuthority(params) => {
return self.handle_pane_clear_agent_authority(request.id, params);
}
Method::PaneReleaseAgent(params) => {
return self.handle_pane_release_agent(request.id, params);
}
Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params),
Method::PaneSendInput(params) => {
return self.handle_pane_send_input(request.id, params)
}
Method::PaneClose(target) => return self.handle_pane_close(request.id, target),
Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params),
Method::IntegrationInstall(params) => {
return self.handle_integration_install(request.id, params);
}
Method::IntegrationUninstall(params) => {
return self.handle_integration_uninstall(request.id, params);
}
_ => {
return responses::encode_error(
request.id,
"not_implemented",
"method not implemented yet",
);
}
};
serde_json::to_string(&response).unwrap()
}
fn handle_notification_show(
&mut self,
id: String,
params: crate::api::schema::NotificationShowParams,
) -> String {
use crate::api::schema::{NotificationShowReason, ResponseResult};
let requested_sound = params.sound;
let Some(title) = sanitized_notification_text(&params.title, 80) else {
return responses::encode_error(id, "invalid_params", "notification title is empty");
};
let body = params
.body
.as_deref()
.and_then(|body| sanitized_notification_text(body, 240));
let reason = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Off => NotificationShowReason::Disabled,
crate::config::ToastDelivery::Herdr => {
if self.state.toast.is_some() {
NotificationShowReason::Busy
} else if self.api_notification_rate_limited(Instant::now()) {
NotificationShowReason::RateLimited
} else {
let previous_toast = self.state.toast.clone();
self.mark_api_notification_shown(Instant::now());
self.state.toast = Some(crate::app::state::ToastNotification {
kind: ToastKind::UpdateInstalled,
title,
context: body.unwrap_or_default(),
position: params.position,
target: None,
});
self.sync_toast_deadline(previous_toast);
self.emit_api_notification_sound(requested_sound);
NotificationShowReason::Shown
}
}
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {
if self.api_notification_rate_limited(Instant::now()) {
NotificationShowReason::RateLimited
} else {
let notify = match self.state.toast_config.delivery {
crate::config::ToastDelivery::Terminal => {
crate::terminal_notify::show_notification
}
crate::config::ToastDelivery::System => {
crate::platform::show_desktop_notification
}
_ => unreachable!("notification delivery was checked above"),
};
match notify(&title, body.as_deref()) {
Ok(true) => {
self.mark_api_notification_shown(Instant::now());
self.emit_api_notification_sound(requested_sound);
NotificationShowReason::Shown
}
Ok(false) | Err(_) => NotificationShowReason::NoForegroundClient,
}
}
}
};
responses::encode_success(
id,
ResponseResult::NotificationShow {
shown: matches!(reason, NotificationShowReason::Shown),
reason,
},
)
}
fn emit_api_notification_sound(&self, sound: crate::api::schema::NotificationShowSound) {
if !self.state.local_sound_playback || !self.state.sound.allows(None) {
return;
}
if let Some(sound) = sound.to_sound() {
crate::sound::play(sound, &self.state.sound);
}
}
pub(crate) fn api_notification_rate_limited(&self, now: Instant) -> bool {
self.last_api_notification_at
.is_some_and(|last| now.duration_since(last) < API_NOTIFICATION_RATE_LIMIT)
}
pub(crate) fn mark_api_notification_shown(&mut self, now: Instant) {
self.last_api_notification_at = Some(now);
}
}
fn sanitized_notification_text(value: &str, max_chars: usize) -> Option<String> {
let mut sanitized = String::new();
let mut previous_space = false;
for ch in value.chars() {
let replacement = if ch == '\n' || ch == '\r' || ch == '\t' {
Some(' ')
} else if ch.is_control() {
None
} else {
Some(ch)
};
let Some(ch) = replacement else {
continue;
};
if ch.is_whitespace() {
if previous_space {
continue;
}
previous_space = true;
sanitized.push(' ');
} else {
previous_space = false;
sanitized.push(ch);
}
if sanitized.chars().count() >= max_chars {
break;
}
}
let sanitized = sanitized.trim().to_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::*;
use crate::detect::{Agent, AgentState};
#[cfg(unix)]
fn init_repo(path: &std::path::Path) {
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(path)
.status()
.unwrap();
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() {
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(),
);
let mut workspace = crate::workspace::Workspace::test_new("stale");
workspace.custom_name = None;
let root = workspace.tabs[0].root_pane;
let terminal_id = workspace.terminal_id(root).cloned().unwrap();
let temp_root = std::env::temp_dir().join(format!(
"herdr-toast-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let stale_cwd = temp_root.join("__herdr_original__");
let live_cwd = temp_root.join("__herdr_projects__");
std::fs::create_dir_all(&stale_cwd).unwrap();
std::fs::create_dir_all(&live_cwd).unwrap();
init_repo(&stale_cwd);
init_repo(&live_cwd);
workspace.identity_cwd = stale_cwd.clone();
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd;
app.state.active = None;
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
app.state.toast_config.delay_seconds = 0;
let (events, _) = tokio::sync::mpsc::channel(4);
let runtime = crate::terminal::TerminalRuntime::spawn(
root,
24,
80,
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
None,
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
app.terminal_runtimes.insert(terminal_id, runtime);
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
assert_eq!(
app.state.toast.as_ref().map(|toast| toast.context.as_str()),
Some("__herdr_projects__ · 1")
);
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
}
let _ = std::fs::remove_dir_all(temp_root);
}
#[cfg(unix)]
#[tokio::test]
async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() {
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(),
);
let mut workspace = crate::workspace::Workspace::test_new("stale");
workspace.custom_name = None;
let root = workspace.tabs[0].root_pane;
let terminal_id = workspace.terminal_id(root).cloned().unwrap();
let temp_root = std::env::temp_dir().join(format!(
"herdr-delayed-toast-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let stale_cwd = temp_root.join("__herdr_original__");
let live_cwd = temp_root.join("__herdr_projects__");
std::fs::create_dir_all(&stale_cwd).unwrap();
std::fs::create_dir_all(&live_cwd).unwrap();
init_repo(&stale_cwd);
init_repo(&live_cwd);
workspace.identity_cwd = stale_cwd.clone();
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
app.state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd;
app.state.active = None;
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
app.state.toast_config.delay_seconds = 1;
let (events, _) = tokio::sync::mpsc::channel(4);
let runtime = crate::terminal::TerminalRuntime::spawn(
root,
24,
80,
live_cwd.clone(),
0,
crate::terminal_theme::TerminalTheme::default(),
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
events,
std::sync::Arc::new(tokio::sync::Notify::new()),
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
None,
)
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
app.terminal_runtimes.insert(terminal_id, runtime);
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
let notification_deadline = app
.state
.next_pending_agent_notification_deadline()
.expect("pending notification deadline");
assert!(app.handle_scheduled_tasks(notification_deadline, false));
assert_eq!(
app.state.toast.as_ref().map(|toast| toast.context.as_str()),
Some("__herdr_projects__ · 1")
);
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
}
let _ = std::fs::remove_dir_all(temp_root);
}
#[tokio::test]
async fn pane_died_respawns_shell_and_clears_restored_agent_session() {
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(),
);
let workspace = crate::workspace::Workspace::test_new("restored");
let pane_id = workspace.tabs[0].root_pane;
let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
let terminal = app
.state
.terminals
.get_mut(&terminal_id)
.expect("test terminal should exist");
terminal.respawn_shell_on_exit = true;
terminal.set_agent_name("codex".into());
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:codex".into(),
agent: "codex".into(),
session_ref: crate::agent_resume::AgentSessionRef::id("codex-session")
.expect("test session id should be valid"),
});
app.handle_internal_event(AppEvent::PaneDied { pane_id });
assert!(
app.find_pane(pane_id).is_some(),
"respawnable agent pane should stay attached after the agent process exits"
);
let terminal = app
.state
.terminals
.get(&terminal_id)
.expect("terminal should survive respawn");
assert!(!terminal.respawn_shell_on_exit);
assert!(terminal.persisted_agent_session.is_none());
assert!(terminal.agent_name.is_none());
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
}
}
#[test]
fn terminal_delivery_does_not_refresh_existing_targeted_toast() {
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.local_terminal_notifications = false;
let mut workspace = crate::workspace::Workspace::test_new("stale");
workspace.custom_name = None;
workspace.identity_cwd = "/__herdr_original__".into();
let root = workspace.tabs[0].root_pane;
let terminal_id = workspace.terminal_id(root).cloned().unwrap();
let workspace_id = workspace.id.clone();
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
app.state.terminals.get_mut(&terminal_id).unwrap().cwd = "/__herdr_projects__".into();
app.state.active = None;
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.toast_config.delivery = crate::config::ToastDelivery::Terminal;
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
app.state.toast = Some(crate::app::state::ToastNotification {
kind: ToastKind::Finished,
title: "codex finished".into(),
context: "__herdr_original__ · 1".into(),
position: None,
target: Some(crate::app::state::ToastTarget {
workspace_id,
pane_id: root,
}),
});
app.handle_internal_event(AppEvent::StateChanged {
pane_id: root,
agent: Some(Agent::Codex),
state: AgentState::Idle,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: std::time::Instant::now(),
});
assert_eq!(
app.state.toast.as_ref().map(|toast| toast.context.as_str()),
Some("__herdr_original__ · 1")
);
}
}