aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:52:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:56:07 +0300
commit3f80947678a9723434aa7fddfe23750b5393e24c (patch)
tree6f463797132acfc51dfb6e135820a052954b4f13 /src/app
parentf76f895efe5b9f19aa9db909354c6c0f2195630b (diff)
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs75
-rw-r--r--src/app/agents.rs83
-rw-r--r--src/app/api.rs115
-rw-r--r--src/app/api/agents.rs177
-rw-r--r--src/app/api/panes.rs33
-rw-r--r--src/app/api_helpers.rs52
-rw-r--r--src/app/ids.rs8
-rw-r--r--src/app/mod.rs64
-rw-r--r--src/app/terminal_targets.rs61
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(&params.kind) else {
@@ -154,7 +165,7 @@ impl App {
candidates: conflicts,
});
}
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(&params.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(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.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, &params.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, &params.target);
};
if terminal.managed_agent_launch_pending() {
return agent_not_ready(id, &params.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, &params.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, &params.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, &params.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(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.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, &params.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(&params.target) {
+ pub(super) fn handle_agent_send_keys(
+ &mut self,
+ id: String,
+ params: AgentSendKeysParams,
+ ) -> String {
+ let resolved = match self.resolve_agent_target(&params.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, &params.target);
+ };
+ let Some(expected_agent) = self
+ .state
+ .terminals
+ .get(terminal_id)
+ .and_then(|terminal| terminal.effective_known_agent())
+ else {
+ return agent_not_ready(id, &params.target);
+ };
let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else {
return agent_not_found(id, &params.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, &params.target);
+ }
+ let encoded = match super::super::api_helpers::encode_api_keys(runtime, &params.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, &params.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,