mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
2466 lines
95 KiB
Rust
2466 lines
95 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
mod agent_view;
|
|
mod agents;
|
|
mod env;
|
|
mod integrations;
|
|
mod layouts;
|
|
mod pane_graphics;
|
|
mod panes;
|
|
pub(crate) mod plugins;
|
|
pub(super) mod responses;
|
|
mod session;
|
|
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);
|
|
#[cfg(windows)]
|
|
const WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE: Duration = Duration::from_secs(2);
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RuntimeExitAction {
|
|
RespawnShell,
|
|
ClosePane,
|
|
}
|
|
|
|
impl App {
|
|
pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool {
|
|
match ev {
|
|
AppEvent::GitStatusRefreshed {
|
|
results,
|
|
cache_updates,
|
|
} => self.handle_git_status_refreshed(results, cache_updates),
|
|
AppEvent::TabBarCommandFinished {
|
|
generation,
|
|
segment_index,
|
|
result,
|
|
} => self.handle_tab_bar_command_finished(generation, segment_index, result),
|
|
ev @ AppEvent::TerminalBell { .. } => {
|
|
self.handle_internal_event(ev);
|
|
false
|
|
}
|
|
ev => {
|
|
self.handle_internal_event(ev);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_git_status_refreshed(
|
|
&mut self,
|
|
results: Vec<crate::workspace::WorkspaceGitStatus>,
|
|
cache_updates: Vec<(std::path::PathBuf, crate::workspace::GitStatusCacheEntry)>,
|
|
) -> bool {
|
|
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();
|
|
}
|
|
let changed = self
|
|
.state
|
|
.apply_workspace_git_statuses(&self.terminal_runtimes, results);
|
|
if changed {
|
|
self.render_dirty.request_generic();
|
|
self.render_notify.notify_one();
|
|
}
|
|
changed
|
|
}
|
|
|
|
pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
|
|
let _ = self.handle_internal_event_with_pane_updates(ev);
|
|
}
|
|
|
|
pub(crate) fn handle_internal_event_with_pane_updates(
|
|
&mut self,
|
|
ev: AppEvent,
|
|
) -> Vec<crate::app::actions::PaneStateUpdate> {
|
|
let mut worktree_restore_failed = false;
|
|
let ev = match ev {
|
|
AppEvent::WorktreeRuntimeRestoreFailed {
|
|
pane_id,
|
|
operation_id,
|
|
} => {
|
|
if !self.claim_worktree_runtime_restore_failure(pane_id, operation_id) {
|
|
return Vec::new();
|
|
}
|
|
worktree_restore_failed = true;
|
|
AppEvent::PaneDied {
|
|
pane_id,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
}
|
|
}
|
|
ev => ev,
|
|
};
|
|
if matches!(
|
|
&ev,
|
|
AppEvent::TerminalBell { .. } | AppEvent::ClipboardWrite { .. }
|
|
) {
|
|
return Vec::new();
|
|
}
|
|
|
|
if let AppEvent::GitStatusRefreshed {
|
|
results,
|
|
cache_updates,
|
|
} = ev
|
|
{
|
|
self.handle_git_status_refreshed(results, cache_updates);
|
|
return Vec::new();
|
|
}
|
|
|
|
if let AppEvent::TabBarCommandFinished {
|
|
generation,
|
|
segment_index,
|
|
result,
|
|
} = ev
|
|
{
|
|
let _ = self.handle_tab_bar_command_finished(generation, segment_index, result);
|
|
return Vec::new();
|
|
}
|
|
|
|
if let AppEvent::PluginCommandFinished {
|
|
log_id,
|
|
finished_unix_ms,
|
|
exit_code,
|
|
stdout,
|
|
stderr,
|
|
error,
|
|
} = ev
|
|
{
|
|
self.state.plugin_commands_in_flight =
|
|
self.state.plugin_commands_in_flight.saturating_sub(1);
|
|
if let Some(log) = self
|
|
.state
|
|
.plugin_command_logs
|
|
.iter_mut()
|
|
.find(|log| log.log_id == log_id)
|
|
{
|
|
log.finished_unix_ms = Some(finished_unix_ms);
|
|
log.exit_code = exit_code;
|
|
log.stdout = Some(stdout);
|
|
log.stderr = Some(stderr);
|
|
log.error = error;
|
|
log.status = if log.error.is_none() && log.exit_code == Some(0) {
|
|
crate::api::schema::PluginCommandStatus::Succeeded
|
|
} else {
|
|
crate::api::schema::PluginCommandStatus::Failed
|
|
};
|
|
}
|
|
return Vec::new();
|
|
}
|
|
|
|
if let AppEvent::WorktreeAddFinished(result) = ev {
|
|
self.handle_api_worktree_add_finished(*result);
|
|
return Vec::new();
|
|
}
|
|
|
|
if let AppEvent::WorktreeRemoveFinished(result) = ev {
|
|
return self.handle_api_worktree_remove_finished(*result);
|
|
}
|
|
|
|
let mut worktree_restore_updates = Vec::new();
|
|
if let AppEvent::PaneDied { pane_id, .. } = &ev {
|
|
if self
|
|
.state
|
|
.popup_pane
|
|
.as_ref()
|
|
.is_some_and(|popup| popup.pane_id == *pane_id)
|
|
{
|
|
self.close_popup_pane();
|
|
return Vec::new();
|
|
}
|
|
if worktree_restore_failed {
|
|
worktree_restore_updates
|
|
.extend(self.publish_worktree_runtime_agent_release(*pane_id));
|
|
} else {
|
|
let expected_exit = self
|
|
.pending_worktree_remove_runtime_exits
|
|
.get_mut(pane_id)
|
|
.map(|remaining| {
|
|
*remaining -= 1;
|
|
*remaining == 0
|
|
});
|
|
if let Some(remove_entry) = expected_exit {
|
|
let restore_failed = if remove_entry {
|
|
self.pending_worktree_remove_runtime_exits.remove(pane_id);
|
|
let restore_requested = self
|
|
.pending_worktree_remove_runtime_restores
|
|
.remove(pane_id)
|
|
.is_some();
|
|
if restore_requested {
|
|
worktree_restore_updates
|
|
.extend(self.publish_worktree_runtime_agent_release(*pane_id));
|
|
}
|
|
restore_requested && !self.respawn_shell_for_launch_pane(*pane_id, false)
|
|
} else {
|
|
false
|
|
};
|
|
if !restore_failed {
|
|
return worktree_restore_updates;
|
|
}
|
|
}
|
|
let previous_toast = self.state.toast.clone();
|
|
if let Some(update) = self
|
|
.state
|
|
.publish_pane_process_exit_if_agent(*pane_id, false)
|
|
{
|
|
self.sync_full_lifecycle_authority_detection_pauses();
|
|
self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
|
|
self.emit_pane_state_update(&update);
|
|
}
|
|
if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell
|
|
&& self.respawn_shell_for_launch_pane(*pane_id, true)
|
|
{
|
|
self.overlay_panes.remove(pane_id);
|
|
self.render_dirty.request_generic();
|
|
self.render_notify.notify_one();
|
|
return worktree_restore_updates;
|
|
}
|
|
}
|
|
}
|
|
|
|
let checkpointed_pane_exit = matches!(
|
|
&ev,
|
|
AppEvent::PaneDied {
|
|
pane_id,
|
|
exit_reason,
|
|
} if exit_reason.requires_session_checkpoint() && self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id)
|
|
);
|
|
if checkpointed_pane_exit {
|
|
self.checkpoint_session_before_pane_exit();
|
|
}
|
|
|
|
let overlay_state = if let AppEvent::PaneDied { pane_id, .. } = &ev {
|
|
self.overlay_panes.remove(pane_id).map(|overlay| {
|
|
let was_overlay_active =
|
|
self.state
|
|
.is_active_pane(overlay.ws_idx, overlay.tab_idx, *pane_id);
|
|
let tab_before_exit = self
|
|
.state
|
|
.workspaces
|
|
.get(overlay.ws_idx)
|
|
.and_then(|ws| ws.tabs.get(overlay.tab_idx));
|
|
let was_overlay_focused_in_tab =
|
|
tab_before_exit.is_some_and(|tab| tab.layout.focused() == *pane_id);
|
|
let tab_zoomed_before_exit = tab_before_exit.map(|tab| tab.zoomed);
|
|
(
|
|
overlay,
|
|
was_overlay_active,
|
|
was_overlay_focused_in_tab,
|
|
tab_zoomed_before_exit,
|
|
)
|
|
})
|
|
} 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 pane_exit_layout_target = if let AppEvent::PaneDied { pane_id, .. } = &ev {
|
|
self.find_pane(*pane_id).and_then(|(ws_idx, _)| {
|
|
self.layout_update_target_after_pane_removal(ws_idx, *pane_id)
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
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 { activated, .. } = &ev {
|
|
Some(activated.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
|
|
let previous_toast = self.state.toast.clone();
|
|
let mut pane_updates = self.state.handle_app_event(ev);
|
|
if update_ready.is_some() {
|
|
self.state.latest_release_notes = crate::release_notes::load_latest();
|
|
}
|
|
if checkpointed_pane_exit {
|
|
self.finish_checkpointed_pane_exit();
|
|
}
|
|
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.request_git_identity_refresh(Instant::now());
|
|
self.render_dirty.request_generic();
|
|
self.render_notify.notify_one();
|
|
}
|
|
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,
|
|
was_overlay_active,
|
|
was_overlay_focused_in_tab,
|
|
tab_zoomed_before_exit,
|
|
)) = overlay_state
|
|
{
|
|
self.restore_overlay_after_exit(
|
|
overlay,
|
|
was_overlay_active,
|
|
was_overlay_focused_in_tab,
|
|
tab_zoomed_before_exit,
|
|
);
|
|
}
|
|
if let Some((ws_idx, tab_idx)) = pane_exit_layout_target {
|
|
self.emit_layout_updated_event(ws_idx, tab_idx);
|
|
}
|
|
|
|
self.sync_toast_deadline(previous_toast);
|
|
self.shutdown_detached_terminal_runtimes();
|
|
pane_updates.extend(worktree_restore_updates);
|
|
pane_updates
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn restore_overlay_after_exit(
|
|
&mut self,
|
|
overlay: OverlayPaneState,
|
|
was_overlay_active: bool,
|
|
was_overlay_focused_in_tab: bool,
|
|
tab_zoomed_before_exit: Option<bool>,
|
|
) {
|
|
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;
|
|
}
|
|
|
|
if !was_overlay_focused_in_tab {
|
|
if let Some(tab_zoomed_before_exit) = tab_zoomed_before_exit {
|
|
ws.tabs[overlay.tab_idx].zoomed = tab_zoomed_before_exit;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if was_overlay_active {
|
|
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 was_overlay_active && 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 || self.should_respawn_shell_after_agent_exit(terminal) {
|
|
RuntimeExitAction::RespawnShell
|
|
} else {
|
|
RuntimeExitAction::ClosePane
|
|
}
|
|
}
|
|
|
|
fn should_respawn_shell_after_agent_exit(
|
|
&self,
|
|
terminal: &crate::terminal::TerminalState,
|
|
) -> bool {
|
|
#[cfg(not(windows))]
|
|
{
|
|
let _ = terminal;
|
|
false
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
if !terminal.agent_process_exited_within(
|
|
Instant::now(),
|
|
WINDOWS_POWERSHELL_AGENT_EXIT_RESPAWN_GRACE,
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
crate::pane::uses_windows_powershell_pane_shell(crate::pane::PaneShellConfig::new(
|
|
&self.state.default_shell,
|
|
self.state.shell_mode,
|
|
))
|
|
}
|
|
}
|
|
|
|
fn respawn_shell_for_launch_pane(
|
|
&mut self,
|
|
pane_id: crate::layout::PaneId,
|
|
focus_pane: bool,
|
|
) -> bool {
|
|
let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else {
|
|
return false;
|
|
};
|
|
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 Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else {
|
|
return false;
|
|
};
|
|
let runtime = match crate::terminal::TerminalRuntime::spawn(
|
|
pane_id,
|
|
rows,
|
|
cols,
|
|
cwd,
|
|
self.state.pane_scrollback_limit_bytes,
|
|
self.state.host_terminal_theme,
|
|
self.state.host_terminal_appearance,
|
|
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
|
|
&launch_env,
|
|
self.event_tx.clone(),
|
|
self.render_notify.clone(),
|
|
self.render_dirty.clone(),
|
|
) {
|
|
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();
|
|
}
|
|
if focus_pane {
|
|
self.state.focus_pane_in_workspace(ws_idx, pane_id);
|
|
}
|
|
self.schedule_session_save();
|
|
true
|
|
}
|
|
|
|
pub(crate) fn claim_worktree_runtime_restore_failure(
|
|
&mut self,
|
|
pane_id: crate::layout::PaneId,
|
|
operation_id: u64,
|
|
) -> bool {
|
|
if self.pending_worktree_remove_runtime_restores.get(&pane_id) != Some(&operation_id) {
|
|
return false;
|
|
}
|
|
self.pending_worktree_remove_runtime_restores
|
|
.remove(&pane_id);
|
|
self.pending_worktree_remove_runtime_exits.remove(&pane_id);
|
|
true
|
|
}
|
|
|
|
pub(crate) fn publish_worktree_runtime_agent_release(
|
|
&mut self,
|
|
pane_id: crate::layout::PaneId,
|
|
) -> Option<crate::app::actions::PaneStateUpdate> {
|
|
self.state.pending_agent_notifications.remove(&pane_id);
|
|
let previous_toast = self.state.toast.clone();
|
|
let update = self
|
|
.state
|
|
.publish_pane_process_exit_if_agent(pane_id, true)?;
|
|
self.sync_full_lifecycle_authority_detection_pauses();
|
|
self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast);
|
|
self.emit_pane_state_update(&update);
|
|
Some(update)
|
|
}
|
|
|
|
fn queue_worktree_runtime_restore_failed(
|
|
&self,
|
|
pane_id: crate::layout::PaneId,
|
|
operation_id: u64,
|
|
) {
|
|
let event_tx = self.event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let _ = event_tx
|
|
.send(AppEvent::WorktreeRuntimeRestoreFailed {
|
|
pane_id,
|
|
operation_id,
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
pub(crate) fn emit_pane_state_update(&mut 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.agent_name_changed {
|
|
self.emit_pane_updated(update.ws_idx, update.pane_id);
|
|
}
|
|
|
|
if update.previous_agent_label != update.agent_label || update.agent_released {
|
|
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(),
|
|
released: update.agent_released,
|
|
final_status: update.agent_release_status,
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
state_labels: presentation.state_labels,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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 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(&mut self, event: crate::api::schema::EventEnvelope) {
|
|
self.run_plugin_event_hooks(&event);
|
|
self.event_hub.push(event);
|
|
}
|
|
|
|
pub(crate) fn emit_pane_updated(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) {
|
|
if let Some(pane) = self.pane_info(ws_idx, pane_id) {
|
|
self.emit_event(crate::api::schema::EventEnvelope {
|
|
event: crate::api::schema::EventKind::PaneUpdated,
|
|
data: crate::api::schema::EventData::PaneUpdated { pane },
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) fn emit_workspace_token_updated(&mut self, ws_idx: usize) {
|
|
// Token updates bypass plugin hooks so a hook cannot refresh its own
|
|
// token and recursively trigger workspace.updated.
|
|
self.event_hub.push(crate::api::schema::EventEnvelope {
|
|
event: crate::api::schema::EventKind::WorkspaceMetadataUpdated,
|
|
data: crate::api::schema::EventData::WorkspaceMetadataUpdated {
|
|
workspace: self.workspace_info(ws_idx),
|
|
},
|
|
});
|
|
}
|
|
|
|
pub(crate) fn sync_focus_events(&mut self) {
|
|
self.sync_focus_events_with_outer_event(None);
|
|
}
|
|
|
|
pub(crate) fn accept_current_focus_without_events(&mut self) {
|
|
self.last_focus = self.state.active.and_then(|idx| {
|
|
self.state
|
|
.workspaces
|
|
.get(idx)
|
|
.and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id)))
|
|
});
|
|
}
|
|
|
|
pub(crate) fn accept_current_focus_with_api_events(&mut self) {
|
|
let current_focus = self.state.active.and_then(|idx| {
|
|
self.state
|
|
.workspaces
|
|
.get(idx)
|
|
.and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id)))
|
|
});
|
|
if current_focus == self.last_focus {
|
|
return;
|
|
}
|
|
self.last_focus = current_focus;
|
|
if let Some((ws_idx, pane_id)) = current_focus {
|
|
self.emit_focus_api_events(ws_idx, pane_id);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn emit_focus_api_events(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) {
|
|
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),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
fn sync_focus_events_with_outer_event(
|
|
&mut self,
|
|
outer_event: Option<crate::ghostty::FocusEvent>,
|
|
) {
|
|
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 {
|
|
if let (Some((ws_idx, pane_id)), Some(event)) = (current_focus, outer_event) {
|
|
self.send_pane_focus_event(ws_idx, pane_id, event);
|
|
}
|
|
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 {
|
|
let event = outer_event.unwrap_or_else(|| {
|
|
if self.state.outer_terminal_focus == Some(false) {
|
|
crate::ghostty::FocusEvent::Lost
|
|
} else {
|
|
crate::ghostty::FocusEvent::Gained
|
|
}
|
|
});
|
|
self.send_pane_focus_event(ws_idx, pane_id, event);
|
|
self.emit_focus_api_events(ws_idx, pane_id);
|
|
}
|
|
|
|
self.last_focus = current_focus;
|
|
}
|
|
|
|
pub(crate) 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);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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 {
|
|
self.sync_pending_terminal_titles();
|
|
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::ReleaseNotesDismiss(params) => {
|
|
let Some(notes) = self.state.latest_release_notes.as_ref() else {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"stale_release_notes",
|
|
"the release notes are no longer current",
|
|
);
|
|
};
|
|
if notes.version != params.version {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"stale_release_notes",
|
|
"the release notes are no longer current",
|
|
);
|
|
}
|
|
let preview = notes.preview;
|
|
self.mark_release_notes_seen(preview);
|
|
return responses::encode_success(request.id, ResponseResult::Ok {});
|
|
}
|
|
Method::ProductAnnouncementDismiss(params) => {
|
|
let matches_current =
|
|
self.state
|
|
.product_announcement
|
|
.as_ref()
|
|
.is_some_and(|announcement| {
|
|
announcement.version == params.version && announcement.id == params.id
|
|
});
|
|
if !matches_current {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"stale_announcement",
|
|
"the product announcement is no longer current",
|
|
);
|
|
}
|
|
self.dismiss_product_announcement();
|
|
return responses::encode_success(request.id, ResponseResult::Ok {});
|
|
}
|
|
Method::CommandInvoke(params) => {
|
|
return self.handle_command_invoke(request.id, params);
|
|
}
|
|
Method::ClientWindowTitleSet(_) | Method::ClientWindowTitleClear(_) => {
|
|
return responses::encode_success(
|
|
request.id,
|
|
ResponseResult::ClientWindowTitle {
|
|
changed: false,
|
|
reason: crate::api::schema::ClientWindowTitleReason::NoForegroundClient,
|
|
},
|
|
);
|
|
}
|
|
Method::SessionSnapshot(_) => return self.handle_session_snapshot(request.id),
|
|
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::WorkspaceMove(params) => {
|
|
return self.handle_workspace_move(request.id, params);
|
|
}
|
|
Method::WorkspaceMoveBlock(params) => {
|
|
return self.handle_workspace_move_block(request.id, params);
|
|
}
|
|
Method::WorkspaceReportMetadata(params) => {
|
|
return self.handle_workspace_report_metadata(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) => {
|
|
let _ = params;
|
|
return responses::encode_error(
|
|
request.id,
|
|
"invalid_request",
|
|
"worktree.create is handled asynchronously by the app runtime",
|
|
);
|
|
}
|
|
Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params),
|
|
Method::WorktreeRemove(params) => {
|
|
let _ = params;
|
|
return responses::encode_error(
|
|
request.id,
|
|
"invalid_request",
|
|
"worktree.remove is handled asynchronously by the app runtime",
|
|
);
|
|
}
|
|
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::TabMove(params) => return self.handle_tab_move(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::AgentViewSet(params) => return self.handle_agent_view_set(request.id, params),
|
|
Method::AgentViewClear(params) => {
|
|
return self.handle_agent_view_clear(request.id, params);
|
|
}
|
|
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
|
|
Method::AgentPrompt(_) => {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"invalid_request",
|
|
"agent.prompt is handled asynchronously by the app runtime",
|
|
);
|
|
}
|
|
Method::AgentWait(_) => {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"invalid_request",
|
|
"agent.wait is handled by the api server",
|
|
);
|
|
}
|
|
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
|
|
Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target),
|
|
Method::AgentSendKeys(params) => {
|
|
return self.handle_agent_send_keys(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::PaneProcessInfo(params) => {
|
|
return self.handle_pane_process_info(request.id, params);
|
|
}
|
|
Method::LayoutExport(params) => return self.handle_layout_export(request.id, params),
|
|
Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params),
|
|
Method::LayoutSetSplitRatio(params) => {
|
|
return self.handle_layout_set_split_ratio(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::PaneScroll(params) => return self.handle_pane_scroll(request.id, params),
|
|
Method::PaneClear(target) => return self.handle_pane_clear(request.id, target),
|
|
Method::PaneEditScrollback(target) => {
|
|
return self.handle_pane_edit_scrollback(request.id, target);
|
|
}
|
|
Method::PaneSelectionRead(params) => {
|
|
return self.handle_pane_selection_read(request.id, params);
|
|
}
|
|
Method::PaneCopyMotion(params) => {
|
|
return self.handle_pane_copy_motion(request.id, params);
|
|
}
|
|
Method::PaneCopySearch(params) => {
|
|
return self.handle_pane_copy_search(request.id, params);
|
|
}
|
|
Method::PaneList(params) => return self.handle_pane_list(request.id, params),
|
|
Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params),
|
|
Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
|
|
Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target),
|
|
Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params),
|
|
Method::PaneLinkResolve(params) => {
|
|
return self.handle_pane_link_resolve(request.id, params);
|
|
}
|
|
Method::PaneLinkActivate(params) => {
|
|
return self.handle_pane_link_activate(request.id, params);
|
|
}
|
|
Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
|
|
Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
|
|
Method::PaneGraphicsSet(params) => {
|
|
return self.handle_pane_graphics_set(request.id, params);
|
|
}
|
|
Method::PaneGraphicsClear(params) => {
|
|
return self.handle_pane_graphics_clear(request.id, params);
|
|
}
|
|
Method::PaneGraphicsInfo(params) => {
|
|
return self.handle_pane_graphics_info(request.id, params);
|
|
}
|
|
Method::PaneGraphicsStream(_) => {
|
|
return responses::encode_error(
|
|
request.id,
|
|
"stream_transport_required",
|
|
"pane.graphics.stream requires the streaming socket transport",
|
|
);
|
|
}
|
|
Method::PaneGraphicsStreamSet(params) => {
|
|
return self.handle_pane_graphics_stream_set(request.id, params);
|
|
}
|
|
Method::PaneGraphicsStreamDirect(params) => {
|
|
return self.handle_pane_graphics_stream_direct(request.id, params);
|
|
}
|
|
Method::PaneGraphicsStreamOpen(params) => {
|
|
return self.handle_pane_graphics_stream_open(request.id, params);
|
|
}
|
|
Method::PaneGraphicsStreamClose(params) => {
|
|
return self.handle_pane_graphics_stream_close(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::PopupClose(_) => {
|
|
return if self.close_popup_pane() {
|
|
responses::encode_success(request.id, ResponseResult::Ok {})
|
|
} else {
|
|
responses::encode_error(request.id, "popup_not_open", "no popup is open")
|
|
};
|
|
}
|
|
Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params),
|
|
Method::IntegrationList(_) => {
|
|
return self.handle_integration_list(request.id);
|
|
}
|
|
Method::IntegrationInstall(params) => {
|
|
return self.handle_integration_install(request.id, params);
|
|
}
|
|
Method::IntegrationUninstall(params) => {
|
|
return self.handle_integration_uninstall(request.id, params);
|
|
}
|
|
Method::PluginLink(params) => {
|
|
return self.handle_plugin_link(request.id, params);
|
|
}
|
|
Method::PluginList(params) => {
|
|
return self.handle_plugin_list(request.id, params);
|
|
}
|
|
Method::PluginUnlink(params) => {
|
|
return self.handle_plugin_unlink(request.id, params);
|
|
}
|
|
Method::PluginEnable(params) => {
|
|
return self.handle_plugin_enable(request.id, params);
|
|
}
|
|
Method::PluginDisable(params) => {
|
|
return self.handle_plugin_disable(request.id, params);
|
|
}
|
|
Method::PluginActionList(params) => {
|
|
return self.handle_plugin_action_list(request.id, params);
|
|
}
|
|
Method::PluginActionInvoke(params) => {
|
|
return self.handle_plugin_action_invoke(request.id, params);
|
|
}
|
|
Method::PluginLogList(params) => {
|
|
return self.handle_plugin_log_list(request.id, params);
|
|
}
|
|
Method::PluginPaneOpen(params) => {
|
|
return self.handle_plugin_pane_open(request.id, params);
|
|
}
|
|
Method::PluginPaneFocus(params) => {
|
|
return self.handle_plugin_pane_focus(request.id, params);
|
|
}
|
|
Method::PluginPaneClose(params) => {
|
|
return self.handle_plugin_pane_close(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 Some(title) = sanitized_notification_text(¶ms.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);
|
|
NotificationShowReason::Shown
|
|
}
|
|
}
|
|
crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {
|
|
NotificationShowReason::NoForegroundClient
|
|
}
|
|
};
|
|
|
|
responses::encode_success(
|
|
id,
|
|
ResponseResult::NotificationShow {
|
|
shown: matches!(reason, NotificationShowReason::Shown),
|
|
reason,
|
|
},
|
|
)
|
|
}
|
|
|
|
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)]
|
|
pub(super) mod test_support {
|
|
pub(crate) fn exiting_test_command() -> &'static str {
|
|
#[cfg(windows)]
|
|
{
|
|
"C:\\Windows\\System32\\whoami.exe"
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
"/usr/bin/true"
|
|
}
|
|
}
|
|
|
|
pub(crate) fn shutdown_test_runtimes(app: &mut crate::app::App) {
|
|
let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
|
|
for (_terminal_id, runtime) in runtimes {
|
|
runtime.shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
fn app_with_overlay(
|
|
workspace: crate::workspace::Workspace,
|
|
overlay_pane: crate::layout::PaneId,
|
|
previous_focus: crate::layout::PaneId,
|
|
previous_zoomed: bool,
|
|
) -> App {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.workspaces = vec![workspace];
|
|
app.state.ensure_test_terminals();
|
|
app.state.active = Some(0);
|
|
app.state.mode = Mode::Terminal;
|
|
app.overlay_panes.insert(
|
|
overlay_pane,
|
|
OverlayPaneState {
|
|
ws_idx: 0,
|
|
tab_idx: 0,
|
|
previous_focus,
|
|
previous_zoomed,
|
|
temp_files: Vec::new(),
|
|
},
|
|
);
|
|
app
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manifest_activation_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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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::new(),
|
|
activated: vec![Agent::Codex],
|
|
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");
|
|
}
|
|
|
|
#[test]
|
|
fn product_announcement_dismiss_requires_current_identity() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.product_announcement = Some(crate::app::state::ProductAnnouncementState {
|
|
version: "0.8.2".into(),
|
|
id: "client-shell".into(),
|
|
title: "Client shell".into(),
|
|
body: "announcement".into(),
|
|
scroll: 0,
|
|
preview: true,
|
|
});
|
|
|
|
let stale = app.handle_api_request(crate::api::schema::Request {
|
|
id: "stale".into(),
|
|
method: crate::api::schema::Method::ProductAnnouncementDismiss(
|
|
crate::api::schema::ProductAnnouncementDismissParams {
|
|
version: "0.8.2".into(),
|
|
id: "old".into(),
|
|
},
|
|
),
|
|
});
|
|
let stale: serde_json::Value = serde_json::from_str(&stale).unwrap();
|
|
assert_eq!(stale["error"]["code"], "stale_announcement");
|
|
assert!(app.state.product_announcement.is_some());
|
|
|
|
let dismissed = app.handle_api_request(crate::api::schema::Request {
|
|
id: "dismiss".into(),
|
|
method: crate::api::schema::Method::ProductAnnouncementDismiss(
|
|
crate::api::schema::ProductAnnouncementDismissParams {
|
|
version: "0.8.2".into(),
|
|
id: "client-shell".into(),
|
|
},
|
|
),
|
|
});
|
|
let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap();
|
|
assert_eq!(dismissed["result"]["type"], "ok");
|
|
assert!(app.state.product_announcement.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn release_notes_dismiss_requires_current_version() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes {
|
|
version: "99.99.99".into(),
|
|
body: "release notes".into(),
|
|
preview: true,
|
|
});
|
|
|
|
let stale = app.handle_api_request(crate::api::schema::Request {
|
|
id: "stale".into(),
|
|
method: crate::api::schema::Method::ReleaseNotesDismiss(
|
|
crate::api::schema::ReleaseNotesDismissParams {
|
|
version: "0.8.2".into(),
|
|
},
|
|
),
|
|
});
|
|
let stale: serde_json::Value = serde_json::from_str(&stale).unwrap();
|
|
assert_eq!(stale["error"]["code"], "stale_release_notes");
|
|
|
|
let dismissed = app.handle_api_request(crate::api::schema::Request {
|
|
id: "dismiss".into(),
|
|
method: crate::api::schema::Method::ReleaseNotesDismiss(
|
|
crate::api::schema::ReleaseNotesDismissParams {
|
|
version: "99.99.99".into(),
|
|
},
|
|
),
|
|
});
|
|
let dismissed: serde_json::Value = serde_json::from_str(&dismissed).unwrap();
|
|
assert_eq!(dismissed["result"]["type"], "ok");
|
|
assert!(app.state.latest_release_notes.is_some());
|
|
}
|
|
|
|
#[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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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_rejects_hook_only_full_lifecycle_authority() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
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["error"]["code"], "agent_not_found");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pane_process_info_returns_response_for_existing_pane() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
app.state.workspaces = vec![crate::workspace::Workspace::test_new("process-info")];
|
|
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);
|
|
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: "process_info".into(),
|
|
method: crate::api::schema::Method::PaneProcessInfo(
|
|
crate::api::schema::PaneProcessInfoParams {
|
|
pane_id: Some(target.clone()),
|
|
},
|
|
),
|
|
});
|
|
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
|
|
|
assert_eq!(response["result"]["type"], "pane_process_info");
|
|
assert_eq!(response["result"]["process_info"]["pane_id"], target);
|
|
}
|
|
|
|
#[test]
|
|
fn client_window_title_api_reports_no_foreground_client_in_app_mode() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
|
|
let set = app.handle_api_request(crate::api::schema::Request {
|
|
id: "title_set".into(),
|
|
method: crate::api::schema::Method::ClientWindowTitleSet(
|
|
crate::api::schema::ClientWindowTitleSetParams {
|
|
title: "plugin review".into(),
|
|
},
|
|
),
|
|
});
|
|
let set: serde_json::Value = serde_json::from_str(&set).unwrap();
|
|
assert_eq!(set["result"]["type"], "client_window_title");
|
|
assert_eq!(set["result"]["changed"], false);
|
|
assert_eq!(set["result"]["reason"], "no_foreground_client");
|
|
|
|
let clear = app.handle_api_request(crate::api::schema::Request {
|
|
id: "title_clear".into(),
|
|
method: crate::api::schema::Method::ClientWindowTitleClear(
|
|
crate::api::schema::EmptyParams::default(),
|
|
),
|
|
});
|
|
let clear: serde_json::Value = serde_json::from_str(&clear).unwrap();
|
|
assert_eq!(clear["result"]["type"], "client_window_title");
|
|
assert_eq!(clear["result"]["reason"], "no_foreground_client");
|
|
}
|
|
|
|
#[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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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(),
|
|
None,
|
|
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
|
|
&crate::pane::PaneLaunchEnv::default(),
|
|
events,
|
|
std::sync::Arc::new(tokio::sync::Notify::new()),
|
|
std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
|
|
)
|
|
.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(),
|
|
crate::app::AppPolicy::TEST,
|
|
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(),
|
|
None,
|
|
crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
|
|
&crate::pane::PaneLaunchEnv::default(),
|
|
events,
|
|
std::sync::Arc::new(tokio::sync::Notify::new()),
|
|
std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
|
|
)
|
|
.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");
|
|
let mut deliveries = app
|
|
.state
|
|
.drain_due_agent_notifications(notification_deadline);
|
|
app.refresh_agent_notification_delivery_contexts(&mut deliveries);
|
|
assert_eq!(
|
|
deliveries
|
|
.first()
|
|
.and_then(|delivery| delivery.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);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_exit_preserves_focus_changed_before_exit() {
|
|
let mut workspace = crate::workspace::Workspace::test_new("overlay");
|
|
let previous_focus = workspace.tabs[0].root_pane;
|
|
let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
|
workspace.tabs[0].zoomed = true;
|
|
let new_tab = workspace.test_add_tab(Some("new"));
|
|
workspace.switch_tab(new_tab);
|
|
let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, true);
|
|
|
|
app.handle_internal_event(AppEvent::PaneDied {
|
|
pane_id: overlay_pane,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
let overlay_tab = &app.state.workspaces[0].tabs[0];
|
|
assert_eq!(app.state.workspaces[0].active_tab, new_tab);
|
|
assert_eq!(overlay_tab.layout.focused(), previous_focus);
|
|
assert!(overlay_tab.zoomed);
|
|
assert!(app.overlay_panes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pane_exit_emits_layout_updated_when_tab_survives() {
|
|
let event_hub = crate::api::EventHub::default();
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
event_hub.clone(),
|
|
);
|
|
let mut workspace = crate::workspace::Workspace::test_new("pane-exit-layout");
|
|
let dead_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
|
app.state.workspaces = vec![workspace];
|
|
app.state.ensure_test_terminals();
|
|
let tab_id = app.public_tab_id(0, 0).unwrap();
|
|
|
|
app.handle_internal_event(AppEvent::PaneDied {
|
|
pane_id: dead_pane,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
let events = event_hub.events_after(0);
|
|
let pane_exited = events
|
|
.iter()
|
|
.position(|(_, event)| event.event == crate::api::schema::EventKind::PaneExited)
|
|
.expect("pane.exited should be emitted");
|
|
let layout_updated = events
|
|
.iter()
|
|
.position(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated)
|
|
.expect("layout.updated should be emitted");
|
|
assert!(pane_exited < layout_updated);
|
|
assert!(matches!(
|
|
&events[layout_updated].1.data,
|
|
crate::api::schema::EventData::LayoutUpdated { layout }
|
|
if layout.tab_id == tab_id && layout.panes.len() == 1
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn idle_agent_exit_emits_release_event_without_a_state_change() {
|
|
for agent_name in [None, Some("reviewer")] {
|
|
let event_hub = crate::api::EventHub::default();
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
event_hub.clone(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("idle-agent-exit");
|
|
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).unwrap();
|
|
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
|
|
if let Some(agent_name) = agent_name {
|
|
terminal.set_agent_name(agent_name.into());
|
|
}
|
|
|
|
app.handle_internal_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Pi),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: true,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
// The release event is this test's subject; the name outliving the
|
|
// observation is pinned by
|
|
// `a_process_exit_observation_alone_does_not_free_the_name`.
|
|
assert_eq!(
|
|
app.state.terminals[&terminal_id].agent_name.as_deref(),
|
|
agent_name
|
|
);
|
|
assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
|
|
&event.data,
|
|
crate::api::schema::EventData::PaneAgentDetected {
|
|
released: true,
|
|
final_status: Some(crate::api::schema::AgentStatus::Idle),
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn process_exit_releases_a_newer_hook_owned_agent() {
|
|
let event_hub = crate::api::EventHub::default();
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
event_hub.clone(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("stale-agent-exit");
|
|
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 observed_at = std::time::Instant::now();
|
|
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
|
|
terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
|
|
terminal
|
|
.set_hook_authority_at(
|
|
"herdr:codex".into(),
|
|
"codex".into(),
|
|
AgentState::Working,
|
|
None,
|
|
None,
|
|
Some(1),
|
|
observed_at + std::time::Duration::from_secs(1),
|
|
)
|
|
.unwrap();
|
|
terminal.set_agent_name("reviewer".into());
|
|
|
|
app.handle_internal_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(Agent::Codex),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: true,
|
|
observed_at,
|
|
});
|
|
|
|
let terminal = &app.state.terminals[&terminal_id];
|
|
assert_eq!(terminal.state, AgentState::Idle);
|
|
// Releasing the registration does not free the name yet; a wrong
|
|
// observation must not cost a live agent the handle its owner gave it.
|
|
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
|
|
assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
|
|
event.data,
|
|
crate::api::schema::EventData::PaneAgentDetected { released: true, .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_exit_layout_updated_uses_restored_zoom_state() {
|
|
let event_hub = crate::api::EventHub::default();
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
event_hub.clone(),
|
|
);
|
|
let mut workspace = crate::workspace::Workspace::test_new("overlay-layout");
|
|
let previous_focus = workspace.tabs[0].root_pane;
|
|
let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
|
workspace.tabs[0].layout.focus_pane(previous_focus);
|
|
workspace.tabs[0].zoomed = true;
|
|
app.state.workspaces = vec![workspace];
|
|
app.state.ensure_test_terminals();
|
|
app.state.active = Some(0);
|
|
app.state.mode = Mode::Terminal;
|
|
let tab_id = app.public_tab_id(0, 0).unwrap();
|
|
app.overlay_panes.insert(
|
|
overlay_pane,
|
|
OverlayPaneState {
|
|
ws_idx: 0,
|
|
tab_idx: 0,
|
|
previous_focus,
|
|
previous_zoomed: false,
|
|
temp_files: Vec::new(),
|
|
},
|
|
);
|
|
|
|
app.handle_internal_event(AppEvent::PaneDied {
|
|
pane_id: overlay_pane,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
let events = event_hub.events_after(0);
|
|
let layout_updated = events
|
|
.iter()
|
|
.rposition(|(_, event)| event.event == crate::api::schema::EventKind::LayoutUpdated)
|
|
.expect("layout.updated should be emitted");
|
|
assert!(matches!(
|
|
&events[layout_updated].1.data,
|
|
crate::api::schema::EventData::LayoutUpdated { layout }
|
|
if layout.tab_id == tab_id && layout.zoomed
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_exit_preserves_same_tab_focus_changed_before_exit() {
|
|
let mut workspace = crate::workspace::Workspace::test_new("overlay");
|
|
let previous_focus = workspace.tabs[0].root_pane;
|
|
let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
|
workspace.tabs[0].layout.focus_pane(previous_focus);
|
|
workspace.tabs[0].zoomed = true;
|
|
let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, false);
|
|
|
|
app.handle_internal_event(AppEvent::PaneDied {
|
|
pane_id: overlay_pane,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
let tab = &app.state.workspaces[0].tabs[0];
|
|
assert_eq!(app.state.workspaces[0].active_tab, 0);
|
|
assert_eq!(tab.layout.focused(), previous_focus);
|
|
assert!(tab.zoomed);
|
|
assert!(app.overlay_panes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_exit_restores_previous_focus_when_overlay_still_focused() {
|
|
let mut workspace = crate::workspace::Workspace::test_new("overlay");
|
|
let previous_focus = workspace.tabs[0].root_pane;
|
|
let overlay_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
|
|
workspace.tabs[0].zoomed = true;
|
|
let mut app = app_with_overlay(workspace, overlay_pane, previous_focus, false);
|
|
|
|
app.handle_internal_event(AppEvent::PaneDied {
|
|
pane_id: overlay_pane,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
let tab = &app.state.workspaces[0].tabs[0];
|
|
assert_eq!(app.state.workspaces[0].active_tab, 0);
|
|
assert_eq!(tab.layout.focused(), previous_focus);
|
|
assert!(!tab.zoomed);
|
|
assert!(app.overlay_panes.is_empty());
|
|
}
|
|
|
|
#[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(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("restored");
|
|
app.state.default_shell = test_support::exiting_test_command().into();
|
|
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,
|
|
exit_reason: crate::platform::ChildExitReason::Exited,
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_powershell_exit_after_agent_process_exit_respawns_shell() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("powershell");
|
|
let pane_id = workspace.tabs[0].root_pane;
|
|
app.state.workspaces = vec![workspace];
|
|
app.state.ensure_test_terminals();
|
|
app.state.default_shell = "powershell.exe".into();
|
|
app.state.shell_mode = crate::config::ShellModeConfig::NonLogin;
|
|
|
|
app.handle_internal_event(AppEvent::StateChanged {
|
|
pane_id,
|
|
agent: Some(crate::detect::Agent::OpenCode),
|
|
state: AgentState::Idle,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
process_exited: true,
|
|
observed_at: std::time::Instant::now(),
|
|
});
|
|
|
|
assert_eq!(
|
|
app.runtime_exit_action(pane_id),
|
|
RuntimeExitAction::RespawnShell
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_powershell_exit_without_recent_agent_process_exit_closes_pane() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("powershell");
|
|
let pane_id = workspace.tabs[0].root_pane;
|
|
app.state.workspaces = vec![workspace];
|
|
app.state.ensure_test_terminals();
|
|
app.state.default_shell = "powershell.exe".into();
|
|
app.state.shell_mode = crate::config::ShellModeConfig::NonLogin;
|
|
|
|
assert_eq!(
|
|
app.runtime_exit_action(pane_id),
|
|
RuntimeExitAction::ClosePane
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stalled_worktree_runtime_exit_closes_pane() {
|
|
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = App::new(
|
|
&crate::config::Config::default(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
let workspace = crate::workspace::Workspace::test_new("worktree");
|
|
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();
|
|
#[cfg(windows)]
|
|
{
|
|
app.state.default_shell = "powershell.exe".into();
|
|
app.state.shell_mode = crate::config::ShellModeConfig::NonLogin;
|
|
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
|
|
terminal.set_detected_state(Some(Agent::Codex), AgentState::Working);
|
|
terminal.set_detected_state_with_screen_signals_at(
|
|
Some(Agent::Codex),
|
|
AgentState::Idle,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
std::time::Instant::now(),
|
|
);
|
|
assert_eq!(
|
|
app.runtime_exit_action(pane_id),
|
|
RuntimeExitAction::RespawnShell
|
|
);
|
|
}
|
|
app.pending_worktree_remove_runtime_exits.insert(pane_id, 1);
|
|
app.pending_worktree_remove_runtime_restores
|
|
.insert(pane_id, 8);
|
|
|
|
app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed {
|
|
pane_id,
|
|
operation_id: 7,
|
|
});
|
|
assert_eq!(
|
|
app.pending_worktree_remove_runtime_restores.get(&pane_id),
|
|
Some(&8)
|
|
);
|
|
assert!(app.event_rx.try_recv().is_err());
|
|
|
|
app.handle_internal_event(AppEvent::WorktreeRuntimeRestoreFailed {
|
|
pane_id,
|
|
operation_id: 8,
|
|
});
|
|
|
|
assert!(app.find_pane(pane_id).is_none());
|
|
assert!(app.terminal_runtimes.get(&terminal_id).is_none());
|
|
assert!(app.pending_worktree_remove_runtime_exits.is_empty());
|
|
assert!(app.pending_worktree_remove_runtime_restores.is_empty());
|
|
}
|
|
|
|
#[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(),
|
|
crate::app::AppPolicy::TEST,
|
|
None,
|
|
api_rx,
|
|
crate::api::EventHub::default(),
|
|
);
|
|
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")
|
|
);
|
|
}
|
|
}
|