diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:52:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:56:07 +0300 |
| commit | 3f80947678a9723434aa7fddfe23750b5393e24c (patch) | |
| tree | 6f463797132acfc51dfb6e135820a052954b4f13 /src/app | |
| parent | f76f895efe5b9f19aa9db909354c6c0f2195630b (diff) | |
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 75 | ||||
| -rw-r--r-- | src/app/agents.rs | 83 | ||||
| -rw-r--r-- | src/app/api.rs | 115 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 177 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 33 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 52 | ||||
| -rw-r--r-- | src/app/ids.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 64 | ||||
| -rw-r--r-- | src/app/terminal_targets.rs | 61 |
9 files changed, 539 insertions, 129 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index b21de729..b7d335b1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -14,6 +14,7 @@ use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; +use super::api_helpers::pane_agent_status; use super::state::{ navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, @@ -245,6 +246,9 @@ pub struct PaneStateUpdate { pub state: AgentState, pub seen: bool, pub presentation: crate::terminal::EffectivePresentation, + pub agent_name_changed: bool, + pub agent_released: bool, + pub agent_release_status: Option<crate::api::schema::AgentStatus>, } // --------------------------------------------------------------------------- @@ -1041,6 +1045,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed: false, + agent_released: false, + agent_release_status: None, }; Some(update) }) @@ -2677,7 +2684,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - let mutation = terminal.set_detected_state_with_screen_signals_at( + Some(terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2685,11 +2692,7 @@ impl AppState { visible_working, process_exited, observed_at, - ); - if process_exited { - terminal.reconcile_managed_agent_at(observed_at, true); - } - Some(mutation) + )) }) .into_iter() .collect(), @@ -2851,16 +2854,27 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let (mutation, managed_changed) = { + let now = Instant::now(); + let (mutation, managed_changed, agent_name_changed, unchanged_change) = { let terminal = self.terminals.get_mut(&terminal_id)?; + let previous_agent_name = terminal.agent_name.clone(); let mutation = update(terminal)?; - let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); - (mutation, managed_changed) + let managed_changed = terminal.reconcile_managed_agent_at(now, false); + let agent_name_changed = terminal.agent_name != previous_agent_name; + let unchanged_change = (mutation.agent_released || agent_name_changed) + .then(|| terminal.unchanged_effective_state_change_at(now)); + ( + mutation, + managed_changed, + agent_name_changed, + unchanged_change, + ) }; - if mutation.session_ref_changed || managed_changed { + if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } - let change = mutation.effective_state_change?; + let agent_released = mutation.agent_released; + let change = mutation.effective_state_change.or(unchanged_change)?; if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { @@ -2881,6 +2895,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed, + agent_released, + agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), }; Some(update) } @@ -2924,7 +2941,7 @@ impl AppState { pane_id: PaneId, ) -> Option<PaneStateUpdate> { let observed_at = std::time::Instant::now(); - self.update_terminal_state(pane_id, |terminal| { + let update = self.update_terminal_state(pane_id, |terminal| { let agent = terminal.effective_known_agent().or(terminal.detected_agent); if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { return None; @@ -2938,7 +2955,8 @@ impl AppState { true, observed_at, )) - }) + })?; + update.agent_released.then_some(update) } fn apply_pane_state_change( @@ -5063,6 +5081,32 @@ mod tests { } #[test] + fn releasing_an_agent_alias_marks_the_session_dirty() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + known_agent: Some(Agent::Pi), + seq: Some(1), + }); + + assert!(state.terminals[&terminal_id].agent_name.is_none()); + assert!(state.session_dirty); + } + + #[test] fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); @@ -5470,6 +5514,11 @@ mod tests { assert_eq!(update.state, AgentState::Idle); assert_eq!(update.agent_label.as_deref(), Some("pi")); assert_eq!(update.known_agent, Some(Agent::Pi)); + assert!(update.agent_released); + assert_eq!( + update.agent_release_status, + Some(crate::api::schema::AgentStatus::Done) + ); assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) diff --git a/src/app/agents.rs b/src/app/agents.rs index f95bf77f..ccdd4af6 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -8,6 +8,14 @@ use crate::api::schema::AgentStartParams; const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); const MAX_AGENT_START_TIMEOUT: Duration = Duration::from_secs(300); const AGENT_START_SETTLE_DELAY: Duration = Duration::from_secs(3); +const INVALID_AGENT_NAME_MESSAGE: &str = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"; + +fn valid_agent_name(name: &str) -> bool { + let mut chars = name.chars(); + matches!(chars.next(), Some('a'..='z')) + && name.len() <= 32 + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) +} impl App { pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> { @@ -27,7 +35,7 @@ impl App { } pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { - let Ok(resolved) = self.resolve_terminal_target(target) else { + let Ok(resolved) = self.resolve_agent_target(target) else { return; }; let Some(terminal_id) = self @@ -55,7 +63,7 @@ impl App { &self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), @@ -66,7 +74,7 @@ impl App { &mut self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); @@ -83,12 +91,13 @@ impl App { name: Option<String>, ) -> Result<crate::api::schema::AgentInfo, AgentRenameError> { let resolved = self - .resolve_terminal_target(target) + .resolve_agent_target(target) .map_err(AgentRenameError::Target)?; - let normalized_name = name.and_then(|name| { - let trimmed = name.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }); + let normalized_name = match name { + Some(name) if valid_agent_name(&name) => Some(name), + Some(_) => return Err(AgentRenameError::InvalidName), + None => None, + }; if let Some(name) = normalized_name.as_deref() { let conflicts = self.agent_name_conflicts(name, &resolved.terminal_id); @@ -113,14 +122,16 @@ impl App { if terminal.managed_agent_launch_pending() { return Err(AgentRenameError::PendingLaunch); } + if terminal.effective_agent_label().is_none() { + return Err(AgentRenameError::NotAgent); + } match normalized_name { - Some(name) => { - terminal.set_agent_name(name.clone()); - terminal.set_manual_label(name); - } + Some(name) => terminal.set_agent_name(name), None => terminal.clear_agent_name(), } self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| { AgentRenameError::Target(TerminalTargetError::NotFound { @@ -133,8 +144,8 @@ impl App { &mut self, params: AgentStartParams, ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> { - let name = params.name.trim().to_string(); - if name.is_empty() { + let name = params.name; + if !valid_agent_name(&name) { return Err(AgentStartError::InvalidName); } let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { @@ -154,7 +165,7 @@ impl App { candidates: conflicts, }); } - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(¶ms.pane_id) else { return Err(AgentStartError::TargetNotFound(params.pane_id)); }; let terminal_id = self @@ -193,7 +204,6 @@ impl App { return Err(AgentStartError::InvalidTimeout); } - let previous_label = terminal.manual_label.clone(); let now = Instant::now(); let terminal = self .state @@ -201,13 +211,8 @@ impl App { .get_mut(&terminal_id) .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); - terminal.set_manual_label(name); if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { terminal.clear_agent_name(); - match previous_label { - Some(label) => terminal.set_manual_label(label), - None => terminal.clear_manual_label(), - } return Err(AgentStartError::InputFailed(err.to_string())); } self.state.mark_session_dirty(); @@ -226,7 +231,7 @@ impl App { match err { AgentStartError::InvalidName => crate::api::schema::ErrorBody { code: "invalid_agent_name".into(), - message: "agent name must not be empty".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), }, AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { code: "unsupported_agent_kind".into(), @@ -318,6 +323,14 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::InvalidName => crate::api::schema::ErrorBody { + code: "invalid_agent_name".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), + }, + AgentRenameError::NotAgent => crate::api::schema::ErrorBody { + code: "agent_not_found".into(), + message: "agent target does not currently host an agent".into(), + }, AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { code: "agent_launch_pending".into(), message: "agent name cannot change while startup is pending".into(), @@ -443,9 +456,35 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + InvalidName, + NotAgent, PendingLaunch, DuplicateName { name: String, candidates: Vec<crate::api::schema::AgentInfo>, }, } + +#[cfg(test)] +mod tests { + use super::valid_agent_name; + + #[test] + fn agent_names_use_a_small_cli_safe_grammar() { + for name in ["a", "reviewer-one", "reviewer_2", &"a".repeat(32)] { + assert!(valid_agent_name(name), "expected {name:?} to be valid"); + } + for name in [ + "", + " reviewer", + "reviewer ", + "reviewer one", + "Reviewer", + "1reviewer", + "reviewer.one", + &"a".repeat(33), + ] { + assert!(!valid_agent_name(name), "expected {name:?} to be invalid"); + } + } +} diff --git a/src/app/api.rs b/src/app/api.rs index 79402e47..7c963ef4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -556,13 +556,19 @@ impl App { }; let workspace_id = self.public_workspace_id(update.ws_idx); - if update.previous_agent_label != update.agent_label { + 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, }, }); } @@ -996,9 +1002,18 @@ impl App { Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params), + 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::AgentSend(params) => return self.handle_agent_send(request.id, params), + 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), @@ -1876,6 +1891,102 @@ mod tests { } #[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(), + true, + 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(), + }); + + assert!(app.state.terminals[&terminal_id].agent_name.is_none()); + 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 stale_detector_exit_does_not_release_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(), + true, + 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::Working); + 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(); diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index d1fbf14c..ffb9cb63 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, - PaneReadResult, ReadFormat, ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams, AgentTarget, + PaneReadResult, ResponseResult, }; use crate::app::App; @@ -59,7 +59,7 @@ impl App { if params.text.is_empty() { return encode_error(id, "empty_agent_prompt", "agent prompt must not be empty"); } - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -75,29 +75,12 @@ impl App { let Some(terminal) = self.state.terminals.get(&terminal_id) else { return agent_not_found(id, ¶ms.target); }; - let Some(expected_agent) = terminal.effective_known_agent().filter(|_| { - terminal.agent_name.as_deref() == Some(params.target.as_str()) - && terminal.state != crate::detect::AgentState::Unknown - }) else { + let Some(expected_agent) = terminal.effective_known_agent() else { return agent_not_ready(id, ¶ms.target); }; if terminal.managed_agent_launch_pending() { return agent_not_ready(id, ¶ms.target); } - if terminal.state == crate::detect::AgentState::Working { - return encode_error( - id, - "agent_working", - format!( - "agent {} is still working; wait before prompting", - params.target - ), - ); - } - if terminal.managed_agent_kind().is_some() && !terminal.managed_agent_interactive_ready() { - return agent_not_ready(id, ¶ms.target); - } - let baseline_state_change_seq = terminal.last_agent_state_change_seq.unwrap_or(0); let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; @@ -118,13 +101,7 @@ impl App { let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - encode_success( - id, - ResponseResult::AgentPrompted { - agent, - baseline_state_change_seq, - }, - ) + encode_success(id, ResponseResult::AgentPrompted { agent }) } pub(super) fn handle_agent_read( @@ -132,7 +109,7 @@ impl App { id: String, params: crate::api::schema::AgentReadParams, ) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -140,21 +117,12 @@ impl App { else { return agent_not_found(id, ¶ms.target); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -178,7 +146,7 @@ impl App { } pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String { - let resolved = match self.resolve_terminal_target(&target.target) { + let resolved = match self.resolve_agent_target(&target.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -248,16 +216,46 @@ impl App { encode_success(id, ResponseResult::AgentExplain { explain: value }) } - pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + pub(super) fn handle_agent_send_keys( + &mut self, + id: String, + params: AgentSendKeysParams, + ) -> String { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = self + .state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.effective_known_agent()) + else { + return agent_not_ready(id, ¶ms.target); + }; let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return encode_error(id, "agent_send_failed", err.to_string()); + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return agent_not_ready(id, ¶ms.target); + } + let encoded = match super::super::api_helpers::encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded) => encoded, + Err(key) => { + return encode_error(id, "invalid_key", format!("unsupported key {key}")); + } + }; + let bytes: Vec<u8> = encoded.into_iter().flatten().collect(); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_send_keys_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -309,7 +307,7 @@ mod tests { } #[tokio::test] - async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() { let mut app = app_with_agent(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] @@ -317,7 +315,7 @@ mod tests { .clone(); let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.set_agent_name("reviewer".into()); - terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 24, 0, b"", 1, @@ -325,11 +323,13 @@ mod tests { runtime.test_process_pty_bytes(b"\x1b[?2004h"); app.state.insert_test_runtime(pane_id, runtime); + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_agent_prompt( "req".into(), AgentPromptParams { - target: "reviewer".into(), + target: public_pane_id, text: "A != B".into(), + wait: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -351,6 +351,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); @@ -363,10 +364,48 @@ mod tests { AgentPromptParams { target: "opencode".into(), text: "wrong target".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); - assert_eq!(error.error.code, "agent_not_ready"); + assert_eq!(error.error.code, "agent_not_found"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_send_keys_validates_every_key_before_writing() { + let mut app = app_with_agent(); + 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 terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let rejected = app.handle_agent_send_keys( + "req-invalid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["enter".into(), "not-a-key".into()], + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert!(rx.try_recv().is_err()); + + let sent = app.handle_agent_send_keys( + "req-valid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["up".into(), "enter".into()], + }, + ); + let success: SuccessResponse = serde_json::from_str(&sent).unwrap(); + assert!(matches!(success.result, ResponseResult::Ok {})); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\x1b[A\r")); assert!(rx.try_recv().is_err()); } @@ -395,6 +434,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); @@ -426,7 +466,7 @@ mod tests { let response = app.handle_agent_focus( "req".into(), AgentTarget { - target: "pi".into(), + target: app.public_pane_id(0, pane_id).unwrap(), }, ); @@ -436,4 +476,33 @@ mod tests { }; assert_eq!(agent.agent_status, AgentStatus::Idle); } + + #[test] + fn agent_rename_does_not_replace_the_pane_label() { + let mut app = app_with_agent(); + 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 terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_manual_label("shell-pane".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let target = app.public_pane_id(0, pane_id).unwrap(); + + for name in [Some("reviewer".to_string()), None] { + let response = app.handle_agent_rename( + "req".into(), + AgentRenameParams { + target: target.clone(), + name, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(success.result, ResponseResult::AgentInfo { .. })); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell-pane") + ); + } + } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6a5b41fe..483d73a5 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -11,7 +11,7 @@ use crate::api::schema::{ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, - PaneZoomResult, ReadFormat, ReadSource, ResponseResult, + PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::App; @@ -1179,21 +1179,12 @@ impl App { else { return pane_not_found(id, ¶ms.pane_id); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -2591,6 +2582,11 @@ mod tests { .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); + app.state + .terminals + .get_mut(&source_terminal) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); let previous_pane_id = app.public_pane_id(0, source).unwrap(); let previous_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); @@ -2633,6 +2629,11 @@ mod tests { Some(&source_terminal) ); assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source))); + assert!(matches!( + app.resolve_agent_target(&previous_pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok()); } #[test] diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c7dc39d2..6c393dac 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -101,6 +101,58 @@ pub(super) fn pane_agent_status( } } +pub(super) fn read_terminal_snapshot( + terminal: &crate::terminal::TerminalRuntime, + source: crate::api::schema::ReadSource, + format: crate::api::schema::ReadFormat, + lines: Option<u32>, +) -> String { + use crate::api::schema::{ReadFormat, ReadSource}; + + let requested_lines = lines.unwrap_or(80).min(1000) as usize; + let text = match (format, source) { + (ReadFormat::Text, ReadSource::Visible) => terminal.visible_text(), + (ReadFormat::Text, ReadSource::Recent) => terminal.recent_text(requested_lines), + (ReadFormat::Text, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_text(requested_lines) + } + (ReadFormat::Text, ReadSource::Detection) => terminal.detection_text(), + (ReadFormat::Ansi, ReadSource::Visible) => terminal.visible_ansi(), + (ReadFormat::Ansi, ReadSource::Recent) => terminal.recent_ansi(requested_lines), + (ReadFormat::Ansi, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_ansi(requested_lines) + } + (ReadFormat::Ansi, ReadSource::Detection) => terminal.detection_text(), + }; + + if lines.is_some() && matches!(source, ReadSource::Visible | ReadSource::Detection) { + last_lines(&text, requested_lines) + } else { + text + } +} + +fn last_lines(text: &str, count: usize) -> String { + if text.is_empty() || count == 0 { + return String::new(); + } + let lines: Vec<_> = text.split_inclusive('\n').collect(); + lines[lines.len().saturating_sub(count)..].concat() +} + +#[cfg(test)] +mod read_snapshot_tests { + use super::last_lines; + + #[test] + fn last_lines_preserves_real_line_endings_and_unicode() { + assert_eq!(last_lines("one\ntwø\n三\n", 2), "twø\n三\n"); + assert_eq!(last_lines("one\ntwo\nthree", 1), "three"); + assert_eq!(last_lines("one\ntwo", 0), ""); + assert_eq!(last_lines("", 2), ""); + } +} + pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { let trimmed = agent.trim(); if trimmed.is_empty() { diff --git a/src/app/ids.rs b/src/app/ids.rs index 9dc4371e..4bebedc6 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -141,4 +141,12 @@ impl App { .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; Some((ws_idx, pane_id)) } + + pub(crate) fn parse_current_public_pane_id( + &self, + id: &str, + ) -> Option<(usize, crate::layout::PaneId)> { + let (ws_idx, pane_id) = self.parse_pane_id(id)?; + (self.public_pane_id(ws_idx, pane_id).as_deref() == Some(id)).then_some((ws_idx, pane_id)) + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index d556b960..bff7c865 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3847,7 +3847,7 @@ mod tests { } #[test] - fn terminal_target_resolves_terminal_id() { + fn terminal_and_agent_targets_treat_terminal_ids_differently() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-id"); let pane = workspace.tabs[0].root_pane; @@ -3857,19 +3857,54 @@ mod tests { app.state.selected = 0; let resolved = app.resolve_terminal_target(&terminal_id).unwrap(); - - assert_eq!(resolved.ws_idx, 0); assert_eq!(resolved.pane_id, pane); assert_eq!(resolved.terminal_id, terminal_id); + + assert!(matches!( + app.resolve_agent_target(&resolved.terminal_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + } + + #[test] + fn agent_target_rejects_a_pane_that_only_has_a_launch_command() { + let mut app = test_app(); + let workspace = Workspace::test_new("terminal-target-command"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .launch_argv = Some(vec!["just".into(), "dev".into()]); + let pane_id = app.public_pane_id(0, pane).unwrap(); + + assert!(app.resolve_terminal_target(&pane_id).is_ok()); + assert!(matches!( + app.resolve_agent_target(&pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); } #[test] - fn terminal_target_resolves_legacy_pane_id() { + fn terminal_target_resolves_pane_id_for_an_agent() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-pane"); let pane = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let attached_terminal_id = app.state.workspaces[0].terminal_id(pane).cloned().unwrap(); + app.state + .terminals + .get_mut(&attached_terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.public_pane_id(0, pane).unwrap(); @@ -3908,6 +3943,27 @@ mod tests { } #[test] + fn agent_target_treats_legacy_pane_syntax_as_a_name() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-target-name"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + 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(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.set_agent_name("p_1".into()); + + let resolved = app.resolve_agent_target("p_1").unwrap(); + + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id.to_string()); + } + + #[test] fn terminal_target_reports_missing_target() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("terminal-target-missing")]; diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index d38d115c..a8fe394a 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -1,7 +1,5 @@ use super::{api_helpers::pane_agent_status, App}; -// Staged for #00f: the agent CLI will use this resolver once #00e provides terminal ids. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTarget { pub ws_idx: usize, @@ -10,8 +8,6 @@ pub(crate) struct TerminalTarget { pub terminal_id: String, } -// Staged for #00f: ambiguity details are produced before the agent facade consumes them. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTargetCandidate { pub terminal_id: String, @@ -22,8 +18,6 @@ pub(crate) struct TerminalTargetCandidate { pub agent_status: crate::api::schema::AgentStatus, } -// Staged for #00f: target errors are specified now and surfaced by agent commands next. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum TerminalTargetError { NotFound { @@ -36,8 +30,6 @@ pub(crate) enum TerminalTargetError { } impl App { - // Staged for #00f: current pane APIs stay pane-targeted while agent APIs will call this. - #[allow(dead_code)] pub(crate) fn resolve_terminal_target( &self, target: &str, @@ -51,13 +43,13 @@ impl App { return Ok(resolved); } - if let Some((ws_idx, pane_id)) = self.parse_pane_id(target) { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { if let Some(resolved) = self.terminal_target_for_pane(ws_idx, pane_id) { return Ok(resolved); } } - let name_matches: Vec<_> = self + let agent_matches: Vec<_> = self .terminal_targets() .into_iter() .filter(|candidate| { @@ -71,6 +63,39 @@ impl App { }) }) .collect(); + if let Some(resolved) = self.single_terminal_match(target, agent_matches)? { + return Ok(resolved); + } + + Err(TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + pub(crate) fn resolve_agent_target( + &self, + target: &str, + ) -> Result<TerminalTarget, TerminalTargetError> { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { + if let Some(resolved) = self + .terminal_target_for_pane(ws_idx, pane_id) + .filter(|resolved| self.target_is_agent(resolved)) + { + return Ok(resolved); + } + } + + let name_matches: Vec<_> = self + .terminal_targets() + .into_iter() + .filter(|candidate| { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .is_some_and(|terminal| terminal.agent_name.as_deref() == Some(target)) + }) + .collect(); if let Some(resolved) = self.single_terminal_match(target, name_matches)? { return Ok(resolved); } @@ -80,8 +105,14 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] + fn target_is_agent(&self, target: &TerminalTarget) -> bool { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == target.terminal_id) + .is_some_and(|terminal| terminal.is_agent_terminal()) + } + fn single_terminal_match( &self, target: &str, @@ -102,8 +133,6 @@ impl App { } } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_targets(&self) -> Vec<TerminalTarget> { self.state .workspaces @@ -127,8 +156,6 @@ impl App { .collect() } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_for_pane( &self, ws_idx: usize, @@ -145,8 +172,6 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_candidate( &self, ws_idx: usize, |
