aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs52
-rw-r--r--src/app/agents.rs360
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/agents.rs185
-rw-r--r--src/app/api/panes.rs51
-rw-r--r--src/app/api_helpers.rs29
-rw-r--r--src/app/mod.rs99
-rw-r--r--src/app/runtime.rs16
8 files changed, 554 insertions, 239 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 3fedeeb3..b4f352b4 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1,6 +1,8 @@
//! Pure state mutations on AppState.
//! These don't need channels, async, or PTY runtime.
+use std::time::Instant;
+
use tracing::{info, warn};
use crate::detect::{Agent, AgentState};
@@ -2583,7 +2585,7 @@ impl AppState {
observed_at,
} => self
.update_terminal_state(pane_id, |terminal| {
- Some(terminal.set_detected_state_with_screen_signals_at(
+ let mutation = terminal.set_detected_state_with_screen_signals_at(
agent,
state,
visible_blocker,
@@ -2591,7 +2593,11 @@ impl AppState {
visible_working,
process_exited,
observed_at,
- ))
+ );
+ if process_exited {
+ terminal.reconcile_managed_agent_at(observed_at, true);
+ }
+ Some(mutation)
})
.into_iter()
.collect(),
@@ -2753,11 +2759,13 @@ impl AppState {
.attached_terminal_id
.clone();
let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen;
- let mutation = {
+ let (mutation, managed_changed) = {
let terminal = self.terminals.get_mut(&terminal_id)?;
- update(terminal)?
+ let mutation = update(terminal)?;
+ let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false);
+ (mutation, managed_changed)
};
- if mutation.session_ref_changed {
+ if mutation.session_ref_changed || managed_changed {
self.mark_session_dirty();
}
let change = mutation.effective_state_change?;
@@ -2785,6 +2793,40 @@ impl AppState {
Some(update)
}
+ pub(crate) fn next_managed_agent_deadline(&self) -> Option<Instant> {
+ self.terminals
+ .values()
+ .filter_map(crate::terminal::TerminalState::next_managed_agent_deadline)
+ .min()
+ }
+
+ pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> {
+ let mut changed_terminals = std::collections::HashSet::new();
+ for (terminal_id, terminal) in &mut self.terminals {
+ if terminal.reconcile_managed_agent_at(now, false) {
+ changed_terminals.insert(terminal_id.clone());
+ }
+ }
+ if changed_terminals.is_empty() {
+ return Vec::new();
+ }
+ self.mark_session_dirty();
+ self.workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, workspace)| {
+ let changed_terminals = &changed_terminals;
+ workspace.tabs.iter().flat_map(move |tab| {
+ tab.panes.iter().filter_map(move |(&pane_id, pane)| {
+ changed_terminals
+ .contains(&pane.attached_terminal_id)
+ .then_some((ws_idx, pane_id))
+ })
+ })
+ })
+ .collect()
+ }
+
pub(crate) fn publish_pane_process_exit_if_agent(
&mut self,
pane_id: PaneId,
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 83f97bd4..f95bf77f 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -1,7 +1,13 @@
-use std::path::PathBuf;
+use std::time::{Duration, Instant};
-use super::{terminal_targets::TerminalTargetError, App, Mode};
-use crate::api::schema::{AgentStartParams, SplitDirection};
+use bytes::Bytes;
+
+use super::{terminal_targets::TerminalTargetError, App};
+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);
impl App {
pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> {
@@ -20,6 +26,31 @@ impl App {
.collect()
}
+ pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) {
+ let Ok(resolved) = self.resolve_terminal_target(target) else {
+ return;
+ };
+ let Some(terminal_id) = self
+ .state
+ .workspaces
+ .get(resolved.ws_idx)
+ .and_then(|workspace| workspace.terminal_id(resolved.pane_id))
+ .cloned()
+ else {
+ return;
+ };
+ let changed = self
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .is_some_and(|terminal| terminal.reconcile_managed_agent_at(Instant::now(), false));
+ if changed {
+ self.state.mark_session_dirty();
+ self.schedule_session_save();
+ self.emit_pane_updated(resolved.ws_idx, resolved.pane_id);
+ }
+ }
+
pub(super) fn agent_info_for_target(
&self,
target: &str,
@@ -79,6 +110,9 @@ impl App {
target: target.to_string(),
}));
};
+ if terminal.managed_agent_launch_pending() {
+ return Err(AgentRenameError::PendingLaunch);
+ }
match normalized_name {
Some(name) => {
terminal.set_agent_name(name.clone());
@@ -98,14 +132,20 @@ impl App {
pub(super) fn start_agent(
&mut self,
params: AgentStartParams,
- extra_env: Vec<(String, String)>,
) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> {
let name = params.name.trim().to_string();
if name.is_empty() {
return Err(AgentStartError::InvalidName);
}
- if params.argv.is_empty() {
- return Err(AgentStartError::EmptyArgv);
+ let Some(kind) = crate::detect::parse_agent_label(&params.kind) else {
+ return Err(AgentStartError::UnsupportedKind(params.kind));
+ };
+ if params
+ .args
+ .iter()
+ .any(|arg| arg.chars().any(char::is_control))
+ {
+ return Err(AgentStartError::InvalidArgument);
}
let conflicts = self.agent_name_conflicts(&name, "");
if !conflicts.is_empty() {
@@ -114,94 +154,68 @@ impl App {
candidates: conflicts,
});
}
-
- let cwd = params
- .cwd
- .map(PathBuf::from)
- .or_else(|| std::env::current_dir().ok())
- .unwrap_or_else(|| PathBuf::from("/"));
- let argv = params.argv;
- let focus = params.focus;
- let (rows, cols) = self.state.estimate_pane_size();
-
- let (ws_idx, tab_idx, pane_id) = if let Some(tab_id) = params.tab_id {
- let (ws_idx, tab_idx) =
- self.parse_tab_id(&tab_id)
- .ok_or_else(|| AgentStartError::TargetNotFound {
- target: tab_id.clone(),
- })?;
- if let Some(workspace_id) = params.workspace_id.as_deref() {
- let requested_ws_idx = self.parse_workspace_id(workspace_id).ok_or_else(|| {
- AgentStartError::TargetNotFound {
- target: workspace_id.to_string(),
- }
- })?;
- if requested_ws_idx != ws_idx {
- return Err(AgentStartError::PlacementConflict);
- }
- }
- let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
- self.spawn_agent_split(
- ws_idx,
- target_pane,
- params.split.unwrap_or(SplitDirection::Right),
- cwd,
- &argv,
- extra_env,
- focus,
- )?
- } else if let Some(workspace_id) = params.workspace_id {
- let ws_idx = self.parse_workspace_id(&workspace_id).ok_or_else(|| {
- AgentStartError::TargetNotFound {
- target: workspace_id.clone(),
- }
- })?;
- let tab_idx = self.state.workspaces[ws_idx].active_tab;
- let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
- self.spawn_agent_split(
- ws_idx,
- target_pane,
- params.split.unwrap_or(SplitDirection::Right),
- cwd,
- &argv,
- extra_env,
- focus,
- )?
- } else if self.state.workspaces.is_empty() {
- self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)?
- } else {
- let ws_idx = self.state.active.unwrap_or(0);
- let tab_idx = self.state.workspaces[ws_idx].active_tab;
- let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused();
- self.spawn_agent_split(
- ws_idx,
- target_pane,
- params.split.unwrap_or(SplitDirection::Right),
- cwd,
- &argv,
- extra_env,
- focus,
- )?
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return Err(AgentStartError::TargetNotFound(params.pane_id));
};
-
let terminal_id = self
.state
.workspaces
.get(ws_idx)
- .and_then(|ws| ws.terminal_id(pane_id))
+ .and_then(|workspace| workspace.terminal_id(pane_id))
.cloned()
- .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?;
- let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
- return Err(AgentStartError::SpawnFailed("terminal disappeared".into()));
- };
- terminal.set_agent_name(name.clone());
+ .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?;
+ let terminal = self
+ .state
+ .terminals
+ .get(&terminal_id)
+ .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?;
+ if terminal.is_agent_terminal() || terminal.managed_agent_kind().is_some() {
+ return Err(AgentStartError::TargetBusy(params.pane_id));
+ }
+ let runtime = self
+ .terminal_runtimes
+ .get(&terminal_id)
+ .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?;
+ let shell_name = available_shell_name(runtime)
+ .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?;
+
+ let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()];
+ argv.extend(params.args);
+ let command = crate::platform::interactive_shell_command(&argv, &shell_name)
+ .ok_or(AgentStartError::InvalidArgument)?;
+ let bytes = crate::app::api_helpers::encode_api_submission(runtime, &command);
+ let timeout = Duration::from_millis(
+ params
+ .timeout_ms
+ .unwrap_or(DEFAULT_AGENT_START_TIMEOUT.as_millis() as u64),
+ );
+ if timeout <= AGENT_START_SETTLE_DELAY || timeout > MAX_AGENT_START_TIMEOUT {
+ return Err(AgentStartError::InvalidTimeout);
+ }
+
+ let previous_label = terminal.manual_label.clone();
+ let now = Instant::now();
+ let terminal = self
+ .state
+ .terminals
+ .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();
+ self.schedule_session_save();
let agent = self
.agent_info(ws_idx, pane_id)
- .ok_or_else(|| AgentStartError::SpawnFailed("agent disappeared".into()))?;
- debug_assert_eq!(agent.tab_id, self.public_tab_id(ws_idx, tab_idx).unwrap());
+ .ok_or(AgentStartError::TargetUnavailable(params.pane_id))?;
Ok((agent, argv))
}
@@ -214,20 +228,33 @@ impl App {
code: "invalid_agent_name".into(),
message: "agent name must not be empty".into(),
},
- AgentStartError::EmptyArgv => crate::api::schema::ErrorBody {
- code: "invalid_agent_argv".into(),
- message: "agent start argv must not be empty".into(),
+ AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody {
+ code: "unsupported_agent_kind".into(),
+ message: format!("unsupported interactive agent kind {kind}"),
},
- AgentStartError::TargetNotFound { target } => crate::api::schema::ErrorBody {
- code: "agent_placement_not_found".into(),
- message: format!("agent placement target {target} not found"),
+ AgentStartError::InvalidArgument => crate::api::schema::ErrorBody {
+ code: "invalid_agent_argument".into(),
+ message: "agent arguments cannot be encoded safely for the target shell".into(),
},
- AgentStartError::PlacementConflict => crate::api::schema::ErrorBody {
- code: "agent_placement_conflict".into(),
- message: "--tab must belong to --workspace".into(),
+ AgentStartError::InvalidTimeout => crate::api::schema::ErrorBody {
+ code: "invalid_agent_timeout".into(),
+ message: "agent start timeout must be greater than 3000ms and at most 300000ms"
+ .into(),
},
- AgentStartError::SpawnFailed(message) => crate::api::schema::ErrorBody {
- code: "agent_start_failed".into(),
+ AgentStartError::TargetNotFound(target) => crate::api::schema::ErrorBody {
+ code: "agent_pane_not_found".into(),
+ message: format!("agent target pane {target} not found"),
+ },
+ AgentStartError::TargetBusy(target) => crate::api::schema::ErrorBody {
+ code: "agent_pane_busy".into(),
+ message: format!("agent target pane {target} is not an available shell"),
+ },
+ AgentStartError::TargetUnavailable(target) => crate::api::schema::ErrorBody {
+ code: "agent_pane_unavailable".into(),
+ message: format!("agent target pane {target} has no live terminal"),
+ },
+ AgentStartError::InputFailed(message) => crate::api::schema::ErrorBody {
+ code: "agent_start_input_failed".into(),
message,
},
AgentStartError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody {
@@ -291,6 +318,10 @@ impl App {
) -> crate::api::schema::ErrorBody {
match err {
AgentRenameError::Target(err) => self.agent_target_error_body(err),
+ AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody {
+ code: "agent_launch_pending".into(),
+ message: "agent name cannot change while startup is pending".into(),
+ },
AgentRenameError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody {
code: "agent_name_taken".into(),
message: format!(
@@ -313,99 +344,7 @@ impl App {
}
}
- fn spawn_agent_workspace(
- &mut self,
- cwd: PathBuf,
- rows: u16,
- cols: u16,
- argv: &[String],
- extra_env: Vec<(String, String)>,
- focus: bool,
- ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
- let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env(
- cwd,
- rows,
- cols,
- argv,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- self.event_tx.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- extra_env,
- )
- .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
- self.terminal_runtimes.insert(terminal.id.clone(), runtime);
- self.state.terminals.insert(terminal.id.clone(), terminal);
- self.state.workspaces.push(ws);
- let ws_idx = self.state.workspaces.len() - 1;
- self.state
- .remove_alias_shadowed_by_new_pane(self.state.workspaces[ws_idx].tabs[0].root_pane);
- if focus || self.state.active.is_none() {
- self.state.switch_workspace(ws_idx);
- self.state.mode = Mode::Terminal;
- }
- self.schedule_session_save();
- let pane_id = self.state.workspaces[ws_idx].tabs[0].root_pane;
- Ok((ws_idx, 0, pane_id))
- }
-
- fn spawn_agent_split(
- &mut self,
- ws_idx: usize,
- target_pane: crate::layout::PaneId,
- split: SplitDirection,
- cwd: PathBuf,
- argv: &[String],
- extra_env: Vec<(String, String)>,
- focus: bool,
- ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
- let (rows, cols) = self.state.estimate_pane_size();
- let previous_focus = self.state.current_pane_focus_target();
- let direction = match split {
- SplitDirection::Right => ratatui::layout::Direction::Horizontal,
- SplitDirection::Down => ratatui::layout::Direction::Vertical,
- };
- let result = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| {
- ws.split_pane_argv_command(
- target_pane,
- direction,
- rows,
- cols,
- Some(cwd),
- argv,
- extra_env,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- focus,
- )
- })
- .ok_or_else(|| AgentStartError::TargetNotFound {
- target: target_pane.raw().to_string(),
- })?
- .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
- self.terminal_runtimes
- .insert(result.1.terminal.id.clone(), result.1.runtime);
- self.state
- .remove_alias_shadowed_by_new_pane(result.1.pane_id);
- self.state
- .terminals
- .insert(result.1.terminal.id.clone(), result.1.terminal);
- if focus {
- self.state.switch_workspace_tab(ws_idx, result.0);
- self.state
- .record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id);
- self.state.mode = Mode::Terminal;
- }
- self.schedule_session_save();
- Ok((ws_idx, result.0, result.1.pane_id))
- }
-
- fn agent_info(
+ pub(super) fn agent_info(
&self,
ws_idx: usize,
pane_id: crate::layout::PaneId,
@@ -434,6 +373,9 @@ impl App {
tab_id: pane.tab_id,
pane_id: pane.pane_id,
focused: pane.focused,
+ launch_pending: terminal.managed_agent_launch_pending(),
+ interactive_ready: terminal.managed_agent_interactive_ready(),
+ state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0),
cwd: pane.cwd,
foreground_cwd: pane.foreground_cwd,
revision: pane.revision,
@@ -454,14 +396,45 @@ impl App {
}
}
+fn available_shell_name(runtime: &crate::terminal::TerminalRuntime) -> Option<String> {
+ #[cfg(test)]
+ if runtime.child_pid().is_none() {
+ return Some("sh".into());
+ }
+ crate::platform::available_pane_shell(runtime.child_pid()?)
+}
+
+pub(super) fn runtime_hosts_agent(
+ runtime: &crate::terminal::TerminalRuntime,
+ expected: crate::detect::Agent,
+) -> bool {
+ #[cfg(test)]
+ if runtime.child_pid().is_none() {
+ return true;
+ }
+ live_runtime_agent(runtime) == Some(expected)
+}
+
+fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option<crate::detect::Agent> {
+ let job = crate::detect::foreground_job(runtime.child_pid()?)?;
+ crate::detect::identify_agent_in_job(&job)
+ .map(|(agent, _)| agent)
+ .or_else(|| {
+ job.processes
+ .iter()
+ .find_map(|process| crate::platform::process_agent_hint(process.pid))
+ })
+}
+
pub(super) enum AgentStartError {
InvalidName,
- EmptyArgv,
- TargetNotFound {
- target: String,
- },
- PlacementConflict,
- SpawnFailed(String),
+ UnsupportedKind(String),
+ InvalidArgument,
+ InvalidTimeout,
+ TargetNotFound(String),
+ TargetBusy(String),
+ TargetUnavailable(String),
+ InputFailed(String),
DuplicateName {
name: String,
candidates: Vec<crate::api::schema::AgentInfo>,
@@ -470,6 +443,7 @@ pub(super) enum AgentStartError {
pub(super) enum AgentRenameError {
Target(TerminalTargetError),
+ PendingLaunch,
DuplicateName {
name: String,
candidates: Vec<crate::api::schema::AgentInfo>,
diff --git a/src/app/api.rs b/src/app/api.rs
index 7bc8edf1..79402e47 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -995,6 +995,7 @@ impl App {
Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target),
Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
+ Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params),
Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target),
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
index b66924ec..d1fbf14c 100644
--- a/src/app/api/agents.rs
+++ b/src/app/api/agents.rs
@@ -1,8 +1,8 @@
use bytes::Bytes;
use crate::api::schema::{
- AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat,
- ReadSource, ResponseResult,
+ AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget,
+ PaneReadResult, ReadFormat, ReadSource, ResponseResult,
};
use crate::app::App;
@@ -19,6 +19,7 @@ impl App {
}
pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String {
+ self.reconcile_managed_agent_target(&target.target);
let agent = match self.agent_info_for_target(&target.target) {
Ok(agent) => agent,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
@@ -46,11 +47,7 @@ impl App {
}
pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String {
- let extra_env = match super::env::normalize_launch_env(params.env.clone()) {
- Ok(env) => env,
- Err((code, message)) => return encode_error(id, &code, message),
- };
- let (agent, argv) = match self.start_agent(params, extra_env) {
+ let (agent, argv) = match self.start_agent(params) {
Ok(started) => started,
Err(err) => return encode_error_body(id, self.agent_start_error_body(err)),
};
@@ -58,6 +55,78 @@ impl App {
encode_success(id, ResponseResult::AgentStarted { agent, argv })
}
+ pub(super) fn handle_agent_prompt(&mut self, id: String, params: AgentPromptParams) -> String {
+ 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) {
+ 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))
+ .cloned()
+ else {
+ return agent_not_found(id, &params.target);
+ };
+ 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 {
+ 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);
+ };
+ if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) {
+ return encode_error(
+ id,
+ "agent_not_ready",
+ format!(
+ "agent {} is no longer the pane foreground process",
+ params.target
+ ),
+ );
+ }
+ let bytes = crate::app::api_helpers::encode_api_submission(runtime, &params.text);
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
+ return encode_error(id, "agent_prompt_failed", err.to_string());
+ }
+ 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,
+ },
+ )
+ }
+
pub(super) fn handle_agent_read(
&mut self,
id: String,
@@ -195,6 +264,14 @@ impl App {
}
}
+fn agent_not_ready(id: String, target: &str) -> String {
+ encode_error(
+ id,
+ "agent_not_ready",
+ format!("agent {target} is not an active named agent"),
+ )
+}
+
fn agent_not_found(id: String, target: &str) -> String {
encode_error(
id,
@@ -231,6 +308,100 @@ mod tests {
app
}
+ #[tokio::test]
+ async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() {
+ 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::OpenCode), AgentState::Idle);
+ let (runtime, mut rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80, 24, 0, b"", 1,
+ );
+ runtime.test_process_pty_bytes(b"\x1b[?2004h");
+ app.state.insert_test_runtime(pane_id, runtime);
+
+ let response = app.handle_agent_prompt(
+ "req".into(),
+ AgentPromptParams {
+ target: "reviewer".into(),
+ text: "A != B".into(),
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::AgentPrompted { agent, .. } = success.result else {
+ panic!("expected prompted response");
+ };
+ assert_eq!(agent.name.as_deref(), Some("reviewer"));
+ assert_eq!(
+ rx.try_recv().unwrap(),
+ Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r")
+ );
+ assert!(rx.try_recv().is_err());
+
+ app.lookup_runtime_sender(0, pane_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b[?2004l");
+ let raw = app.handle_agent_prompt(
+ "req-raw".into(),
+ AgentPromptParams {
+ target: "reviewer".into(),
+ text: "A != B".into(),
+ },
+ );
+ let raw: SuccessResponse = serde_json::from_str(&raw).unwrap();
+ assert!(matches!(raw.result, ResponseResult::AgentPrompted { .. }));
+ assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"A != B\r"));
+ assert!(rx.try_recv().is_err());
+
+ let rejected = app.handle_agent_prompt(
+ "req-label".into(),
+ AgentPromptParams {
+ target: "opencode".into(),
+ text: "wrong target".into(),
+ },
+ );
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap();
+ assert_eq!(error.error.code, "agent_not_ready");
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn agent_prompt_rejects_managed_agent_while_startup_is_pending() {
+ 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();
+ let now = std::time::Instant::now();
+ terminal.begin_managed_agent(
+ "reviewer".into(),
+ Agent::OpenCode,
+ now,
+ std::time::Duration::from_secs(3),
+ std::time::Duration::from_secs(10),
+ );
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ app.state.insert_test_runtime(pane_id, runtime);
+
+ let response = app.handle_agent_prompt(
+ "req-pending".into(),
+ AgentPromptParams {
+ target: "reviewer".into(),
+ text: "A != B".into(),
+ },
+ );
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "agent_not_ready");
+ assert!(rx.try_recv().is_err());
+ }
+
#[test]
fn agent_focus_marks_already_focused_done_agent_seen() {
let mut app = app_with_agent();
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 17569682..6a5b41fe 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -20,9 +20,8 @@ use crate::app::Mode;
use crate::layout::{find_in_direction, NavDirection, PaneId};
use super::super::api_helpers::{
- detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source,
- normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label,
- MAX_METADATA_TOKEN_KEYS_PER_RESOURCE,
+ detect_state_from_api, encode_api_keys, normalize_metadata_source, normalize_metadata_tokens,
+ normalize_metadata_ttl, normalize_reported_agent_label, MAX_METADATA_TOKEN_KEYS_PER_RESOURCE,
};
#[cfg(test)]
use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS};
@@ -1499,20 +1498,16 @@ impl App {
let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- let encoded_keys = match encode_api_keys(runtime, &params.keys) {
- Ok(encoded_keys) => encoded_keys,
+ let bytes = match super::super::api_helpers::encode_api_input(
+ runtime,
+ &params.text,
+ &params.keys,
+ ) {
+ Ok(bytes) => bytes,
Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")),
};
- if !params.text.is_empty() {
- let text_bytes = encode_api_text(runtime, &params.text);
- if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) {
- return encode_error(id, "pane_send_failed", err.to_string());
- }
- }
- for bytes in encoded_keys {
- if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
- return encode_error(id, "pane_send_failed", err.to_string());
- }
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
+ return encode_error(id, "pane_send_failed", err.to_string());
}
encode_success(id, ResponseResult::Ok {})
@@ -2072,6 +2067,32 @@ mod tests {
}
#[tokio::test]
+ async fn api_pane_send_input_brackets_text_and_enter_atomically() {
+ let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1);
+ let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ app.lookup_runtime_sender(0, internal_pane_id)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b[?2004h");
+
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams {
+ pane_id,
+ text: "A != B".into(),
+ keys: vec!["Enter".into()],
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.result, ResponseResult::Ok {});
+ assert_eq!(
+ rx.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r")
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
async fn api_pane_send_input_keys_accept_key_combo_chords() {
let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1);
diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs
index c0a98357..c7dc39d2 100644
--- a/src/app/api_helpers.rs
+++ b/src/app/api_helpers.rs
@@ -48,6 +48,35 @@ pub(super) fn encode_api_keys(
Ok(encoded_keys)
}
+pub(super) fn encode_api_submission(
+ runtime: &crate::terminal::TerminalRuntime,
+ text: &str,
+) -> Vec<u8> {
+ let mut bytes = encode_api_text(runtime, text);
+ let enter = crossterm::event::KeyEvent::new(
+ crossterm::event::KeyCode::Enter,
+ crossterm::event::KeyModifiers::NONE,
+ );
+ bytes.extend_from_slice(&runtime.encode_terminal_key(enter.into()));
+ bytes
+}
+
+pub(super) fn encode_api_input(
+ runtime: &crate::terminal::TerminalRuntime,
+ text: &str,
+ keys: &[String],
+) -> Result<Vec<u8>, String> {
+ let mut bytes = if text.is_empty() {
+ Vec::new()
+ } else {
+ encode_api_text(runtime, text)
+ };
+ for encoded in encode_api_keys(runtime, keys)? {
+ bytes.extend_from_slice(&encoded);
+ }
+ Ok(bytes)
+}
+
pub(super) fn detect_state_from_api(
state: crate::api::schema::PaneAgentState,
) -> crate::detect::AgentState {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 578ea0cd..b6f2bbb8 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -4146,42 +4146,103 @@ mod tests {
}
#[tokio::test]
- async fn focused_agent_start_records_previous_pane() {
+ async fn unavailable_agent_start_does_not_mutate_topology() {
let mut app = test_app();
- let workspace = Workspace::test_new("agent-start-focus");
+ let workspace = Workspace::test_new("agent-start-target");
let root = workspace.tabs[0].root_pane;
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
+ let pane_id = app.pane_info(0, root).unwrap().pane_id;
let response = app.handle_api_request(crate::api::schema::Request {
- id: "req_agent_start_focus".into(),
+ id: "req_agent_start_target".into(),
method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams {
name: "worker".into(),
- cwd: None,
- workspace_id: None,
- tab_id: None,
- split: Some(crate::api::schema::SplitDirection::Right),
- focus: true,
- argv: vec![exiting_test_command().into()],
- env: Default::default(),
+ kind: "pi".into(),
+ pane_id,
+ args: Vec::new(),
+ timeout_ms: Some(1_000),
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
- assert_eq!(response["result"]["type"], "agent_started");
- assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_eq!(response["error"]["code"], "agent_pane_unavailable");
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ }
- app.state.last_pane();
+ #[tokio::test]
+ async fn failed_agent_start_input_rolls_back_and_can_retry() {
+ let mut app = test_app();
+ let workspace = Workspace::test_new("agent-start-input-failure");
+ let root = workspace.tabs[0].root_pane;
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.pane_info(0, root).unwrap().pane_id;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&root]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_manual_label("shell".into());
+ let (runtime, mut receiver) =
+ crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1);
+ runtime
+ .try_send_bytes(bytes::Bytes::from_static(b"occupied"))
+ .unwrap();
+ app.terminal_runtimes.insert(terminal_id.clone(), runtime);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ let request = || crate::api::schema::Request {
+ id: "req_agent_start_input".into(),
+ method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams {
+ name: "worker".into(),
+ kind: "pi".into(),
+ pane_id: pane_id.clone(),
+ args: Vec::new(),
+ timeout_ms: Some(4_000),
+ }),
+ };
+ let response = app.handle_api_request(request());
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["error"]["code"], "agent_start_input_failed");
+ assert_eq!(app.state.terminals[&terminal_id].agent_name, None);
+ assert_eq!(
+ app.state.terminals[&terminal_id].manual_label.as_deref(),
+ Some("shell")
+ );
- let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
- for (_terminal_id, runtime) in runtimes {
- runtime.shutdown();
- }
+ assert_eq!(
+ receiver.try_recv().unwrap(),
+ bytes::Bytes::from_static(b"occupied")
+ );
+ let retry = app.handle_api_request(request());
+ let retry: serde_json::Value = serde_json::from_str(&retry).unwrap();
+ assert_eq!(retry["result"]["type"], "agent_started");
+ assert_eq!(
+ app.state.terminals[&terminal_id].agent_name.as_deref(),
+ Some("worker")
+ );
+ let rename = app.handle_api_request(crate::api::schema::Request {
+ id: "req_agent_rename_pending".into(),
+ method: crate::api::schema::Method::AgentRename(
+ crate::api::schema::AgentRenameParams {
+ target: pane_id,
+ name: Some("replacement".into()),
+ },
+ ),
+ });
+ let rename: serde_json::Value = serde_json::from_str(&rename).unwrap();
+ assert_eq!(rename["error"]["code"], "agent_launch_pending");
+ assert_eq!(
+ app.state.terminals[&terminal_id].agent_name.as_deref(),
+ Some("worker")
+ );
}
#[test]
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index cad49372..97edc471 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -261,6 +261,21 @@ impl App {
}
if self
+ .state
+ .next_managed_agent_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
+ let panes = self.state.reconcile_managed_agents_at(now);
+ if !panes.is_empty() {
+ for (ws_idx, pane_id) in panes {
+ self.emit_pane_updated(ws_idx, pane_id);
+ }
+ self.schedule_session_save();
+ changed = true;
+ }
+ }
+
+ if self
.copy_feedback_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -593,6 +608,7 @@ impl App {
self.config_diagnostic_deadline,
self.toast_deadline,
self.state.next_pending_agent_notification_deadline(),
+ self.state.next_managed_agent_deadline(),
self.copy_feedback_deadline,
self.next_animation_tick,
include_git_refresh